#![forbid(unsafe_code)]
#![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, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Polarity {
DenyBreach,
AllowlistGap,
}
impl Polarity {
pub fn as_str(&self) -> &'static str {
match self {
Polarity::DenyBreach => "deny_breach",
Polarity::AllowlistGap => "allowlist_gap",
}
}
}
#[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>,
pub anchor: Option<String>,
pub polarity: Option<Polarity>,
}
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,
anchor: None,
polarity: None,
}
}
pub fn with_file(mut self, file: Option<String>) -> Self {
self.file = file;
self
}
pub fn with_anchor(mut self, anchor: Option<String>) -> Self {
self.anchor = anchor;
self
}
pub fn with_polarity(mut self, polarity: Polarity) -> Self {
self.polarity = Some(polarity);
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,
"anchor": self.anchor,
"polarity": self.polarity.map(|p| p.as_str()),
})
}
}
#[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, 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, Default)]
pub struct Baseline {
entries: Vec<BaselineEntry>,
}
impl Baseline {
pub fn of(report: &Report) -> Self {
let mut entries: Vec<BaselineEntry> = report
.violations
.iter()
.map(|violation| BaselineEntry {
id: violation.id(),
owner: None,
tracker: None,
})
.collect();
sort_dedup_by_id(&mut entries);
Baseline { entries }
}
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| 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 }
}
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| 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| id == &entry.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 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 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}`"))
};
let optional = |name: &str| item[name].as_str().map(str::to_string);
entries.push(BaselineEntry {
id: ViolationId {
target: field("target")?,
rule: field("rule")?,
finding: field("finding")?,
},
owner: optional("owner"),
tracker: optional("tracker"),
});
}
sort_dedup_by_id(&mut entries);
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());
}
#[test]
fn to_json_emits_the_anchor_key_in_both_states() {
let without = sample_violation();
assert_eq!(without.to_json()["anchor"], Value::Null);
let with = sample_violation().with_anchor(Some("ADR-014".to_string()));
assert_eq!(
with.to_json()["anchor"],
Value::String("ADR-014".to_string())
);
}
#[test]
fn anchor_is_not_part_of_the_baseline_identity() {
let without = sample_violation();
let with = sample_violation().with_anchor(Some("ADR-014".to_string()));
assert_eq!(without.id(), with.id());
}
#[test]
fn to_json_emits_the_polarity_key_in_both_states() {
let without = sample_violation();
assert_eq!(without.to_json()["polarity"], Value::Null);
let deny = sample_violation().with_polarity(Polarity::DenyBreach);
assert_eq!(
deny.to_json()["polarity"],
Value::String("deny_breach".to_string())
);
let allow = sample_violation().with_polarity(Polarity::AllowlistGap);
assert_eq!(
allow.to_json()["polarity"],
Value::String("allowlist_gap".to_string())
);
}
#[test]
fn polarity_is_not_part_of_the_baseline_identity() {
let without = sample_violation();
let with = sample_violation().with_polarity(Polarity::AllowlistGap);
assert_eq!(without.id(), with.id());
}
#[test]
fn baseline_round_trips_through_json() {
let report = Report::new(vec![
sample_violation(),
Violation::new(
BoundaryKind::Crate,
"core".to_string(),
"deny external dependencies".to_string(),
"serde".to_string(),
"core stays dependency-light".to_string(),
Severity::Enforce,
),
]);
let original = Baseline::of(&report);
let reparsed = Baseline::from_json(&original.to_json()).expect("round-trips");
assert!(reparsed.contains(&sample_violation()));
assert!(
reparsed.stale(&report).is_empty(),
"no entry is stale against its own report"
);
assert_eq!(reparsed.to_json(), original.to_json());
}
#[test]
fn owner_and_tracker_round_trip_and_are_some_only() {
let json = r#"{"version":1,"violations":[
{"target":"core","rule":"r","finding":"serde","owner":"team-core","tracker":"ISSUE-7"},
{"target":"zeta","rule":"r","finding":"tokio"}
]}"#;
let baseline = Baseline::from_json(json).expect("parses (old + annotated entries)");
let entries: Vec<&BaselineEntry> = baseline.entries().collect();
assert_eq!(entries[0].id.target, "core");
assert_eq!(entries[0].owner.as_deref(), Some("team-core"));
assert_eq!(entries[0].tracker.as_deref(), Some("ISSUE-7"));
assert_eq!(entries[1].owner, None);
assert_eq!(entries[1].tracker, None);
let out = baseline.to_json();
assert_eq!(Baseline::from_json(&out).unwrap().to_json(), out);
let doc: Value = serde_json::from_str(&out).unwrap();
let zeta = &doc["violations"][1];
assert_eq!(zeta["target"], "zeta");
assert!(zeta.get("owner").is_none() && zeta.get("tracker").is_none());
}
#[test]
fn of_preserving_carries_surviving_metadata_drops_stale_and_none_for_new() {
let previous = Baseline::from_json(
r#"{"version":1,"violations":[
{"target":"core","rule":"r","finding":"serde","owner":"team-core","tracker":"ISSUE-7"},
{"target":"gone","rule":"r","finding":"old","owner":"team-x"}
]}"#,
)
.unwrap();
let mk = |t: &str, f: &str| {
Violation::new(
BoundaryKind::Crate,
t.to_string(),
"r".to_string(),
f.to_string(),
"x".to_string(),
Severity::Enforce,
)
};
let report = Report::new(vec![mk("core", "serde"), mk("new", "reqwest")]);
let next = Baseline::of_preserving(&report, &previous);
let entries: Vec<&BaselineEntry> = next.entries().collect();
assert_eq!(entries.len(), 2);
let core = entries.iter().find(|e| e.id.target == "core").unwrap();
assert_eq!(core.owner.as_deref(), Some("team-core"));
assert_eq!(core.tracker.as_deref(), Some("ISSUE-7"));
let new = entries.iter().find(|e| e.id.target == "new").unwrap();
assert_eq!(new.owner, None);
assert!(
entries.iter().all(|e| e.id.target != "gone"),
"a resolved violation's entry (and metadata) drops"
);
}
#[test]
fn a_duplicate_identity_keeps_the_first_entry() {
let baseline = Baseline::from_json(
r#"{"version":1,"violations":[
{"target":"core","rule":"r","finding":"serde","owner":"first"},
{"target":"core","rule":"r","finding":"serde","owner":"second"}
]}"#,
)
.unwrap();
let entries: Vec<&BaselineEntry> = baseline.entries().collect();
assert_eq!(entries.len(), 1, "de-duplicated by identity");
assert_eq!(
entries[0].owner.as_deref(),
Some("first"),
"keep-first tie-break"
);
}
#[test]
fn a_malformed_or_unknown_version_baseline_is_an_error_not_empty() {
assert!(
Baseline::from_json("{ not json").is_err(),
"malformed JSON is an error"
);
assert!(
Baseline::from_json(r#"{"version": 2, "violations": []}"#).is_err(),
"an unknown version is an error, not a silently-empty baseline"
);
assert!(
Baseline::from_json(r#"{"violations": []}"#).is_err(),
"a missing version is an error"
);
assert!(
Baseline::from_json(r#"{"version": 1, "violations": []}"#)
.expect("valid empty baseline")
.stale(&Report::empty())
.is_empty()
);
}
#[test]
fn a_fixed_violation_leaves_a_stale_baseline_entry() {
let baseline = Baseline::of(&Report::new(vec![sample_violation()]));
let stale = baseline.stale(&Report::empty());
assert_eq!(
stale.len(),
1,
"the fixed violation's entry is reported stale"
);
assert_eq!(stale[0], &sample_violation().id());
}
}