use crate::ast::{Cardinality, ExprKind};
use crate::compiler::CompiledProgram;
use crate::engine::{DecisionStatus, Input, Query, evaluate_query};
use crate::value::Value;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt;
pub const DECISION_SNAPSHOT_SCHEMA_VERSION: &str = "tess.decision-snapshot/v1";
pub const SNAPSHOT_DIFF_SCHEMA_VERSION: &str = "tess.snapshot-diff/v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionSnapshot {
pub schema_version: String,
pub module: String,
pub cases: Vec<CaseSnapshot>,
}
impl DecisionSnapshot {
#[must_use]
pub fn decision_count(&self) -> usize {
self.cases.iter().map(|case| case.decisions.len()).sum()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseSnapshot {
pub case: String,
pub decisions: Vec<DecisionObservation>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionObservation {
pub query: String,
pub occurrence: usize,
pub cardinality: Cardinality,
pub result: SnapshotDecisionStatus,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum SnapshotDecisionStatus {
Resolved { values: Vec<Value> },
Undefined,
Unknown { missing: Vec<String> },
Conflict { values: Vec<Value> },
Error { message: String },
}
impl SnapshotDecisionStatus {
#[must_use]
pub fn render_text(&self) -> String {
match self {
Self::Resolved { values } => format!("resolved [{}]", render_values(values)),
Self::Undefined => "undefined".to_owned(),
Self::Unknown { missing } => format!("unknown (missing {})", missing.join(", ")),
Self::Conflict { values } => format!("conflict [{}]", render_values(values)),
Self::Error { message } => format!("error ({message})"),
}
}
}
fn render_values(values: &[Value]) -> String {
values
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SnapshotBuildError {
pub case: String,
pub message: String,
}
impl fmt::Display for SnapshotBuildError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "test `{}`: {}", self.case, self.message)
}
}
impl Error for SnapshotBuildError {}
pub fn capture_decision_snapshot(
program: &CompiledProgram,
case_filter: Option<&str>,
) -> Result<DecisionSnapshot, SnapshotBuildError> {
let mut cases = Vec::new();
for case in program.cases().values() {
if case_filter.is_some_and(|filter| case.name.value != filter) {
continue;
}
let input = Input::from_case(program, case).map_err(|diagnostics| SnapshotBuildError {
case: case.name.value.clone(),
message: diagnostics
.into_iter()
.map(|diagnostic| diagnostic.message)
.collect::<Vec<_>>()
.join("; "),
})?;
let mut occurrences = BTreeMap::<String, usize>::new();
let mut decisions = Vec::with_capacity(case.expectations.len());
for expectation in &case.expectations {
let arguments = expectation
.arguments
.iter()
.map(|argument| match &argument.kind {
ExprKind::Name(name) => Ok(name.clone()),
_ => Err(SnapshotBuildError {
case: case.name.value.clone(),
message: "an expectation contains a non-name query argument".to_owned(),
}),
})
.collect::<Result<Vec<_>, _>>()?;
let query = Query {
decision: expectation.decision.value.clone(),
arguments,
};
let rendered = query.render();
let occurrence = occurrences.entry(rendered.clone()).or_default();
*occurrence += 1;
let (cardinality, result) = match evaluate_query(program, &input, &query) {
Ok(evaluation) => (
evaluation.result.cardinality,
canonical_status(evaluation.result.status),
),
Err(error) => (
program
.decision(&query.decision)
.map_or(Cardinality::ExactlyOne, |decision| decision.cardinality),
SnapshotDecisionStatus::Error {
message: error.message,
},
),
};
decisions.push(DecisionObservation {
query: rendered,
occurrence: *occurrence,
cardinality,
result,
});
}
decisions.sort_by(|left, right| {
(&left.query, left.occurrence).cmp(&(&right.query, right.occurrence))
});
cases.push(CaseSnapshot {
case: case.name.value.clone(),
decisions,
});
}
cases.sort_by(|left, right| left.case.cmp(&right.case));
Ok(DecisionSnapshot {
schema_version: DECISION_SNAPSHOT_SCHEMA_VERSION.to_owned(),
module: program.ast().module.value.clone(),
cases,
})
}
fn canonical_status(status: DecisionStatus) -> SnapshotDecisionStatus {
match status {
DecisionStatus::Resolved { mut values } => {
values.sort();
values.dedup();
SnapshotDecisionStatus::Resolved { values }
}
DecisionStatus::Undefined => SnapshotDecisionStatus::Undefined,
DecisionStatus::Unknown { missing } => SnapshotDecisionStatus::Unknown {
missing: canonical_strings(missing),
},
DecisionStatus::Conflict { mut values, .. } => {
values.sort();
values.dedup();
SnapshotDecisionStatus::Conflict { values }
}
}
}
fn canonical_strings(values: Vec<String>) -> Vec<String> {
BTreeSet::from_iter(values).into_iter().collect()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotDiff {
pub schema_version: String,
pub baseline_module: String,
pub current_module: String,
pub unchanged_decisions: usize,
pub changes: Vec<SnapshotChange>,
}
impl SnapshotDiff {
#[must_use]
pub fn matches(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "change", rename_all = "snake_case")]
pub enum SnapshotChange {
ModuleChanged {
before: String,
after: String,
},
CaseAdded {
case: String,
decisions: Vec<DecisionObservation>,
},
CaseRemoved {
case: String,
decisions: Vec<DecisionObservation>,
},
DecisionAdded {
case: String,
decision: DecisionObservation,
},
DecisionRemoved {
case: String,
decision: DecisionObservation,
},
DecisionChanged {
case: String,
before: DecisionObservation,
after: DecisionObservation,
},
}
#[must_use]
pub fn diff_decision_snapshots(
baseline: &DecisionSnapshot,
current: &DecisionSnapshot,
) -> SnapshotDiff {
let mut changes = Vec::new();
if baseline.module != current.module {
changes.push(SnapshotChange::ModuleChanged {
before: baseline.module.clone(),
after: current.module.clone(),
});
}
let baseline_cases = baseline
.cases
.iter()
.map(|case| (case.case.as_str(), case))
.collect::<BTreeMap<_, _>>();
let current_cases = current
.cases
.iter()
.map(|case| (case.case.as_str(), case))
.collect::<BTreeMap<_, _>>();
let case_names = baseline_cases
.keys()
.chain(current_cases.keys())
.copied()
.collect::<BTreeSet<_>>();
let mut unchanged_decisions = 0;
for case_name in case_names {
match (baseline_cases.get(case_name), current_cases.get(case_name)) {
(None, Some(current_case)) => changes.push(SnapshotChange::CaseAdded {
case: case_name.to_owned(),
decisions: current_case.decisions.clone(),
}),
(Some(baseline_case), None) => changes.push(SnapshotChange::CaseRemoved {
case: case_name.to_owned(),
decisions: baseline_case.decisions.clone(),
}),
(Some(baseline_case), Some(current_case)) => {
let baseline_decisions = decisions_by_identity(&baseline_case.decisions);
let current_decisions = decisions_by_identity(¤t_case.decisions);
let identities = baseline_decisions
.keys()
.chain(current_decisions.keys())
.cloned()
.collect::<BTreeSet<_>>();
for identity in identities {
match (
baseline_decisions.get(&identity),
current_decisions.get(&identity),
) {
(None, Some(decision)) => changes.push(SnapshotChange::DecisionAdded {
case: case_name.to_owned(),
decision: (*decision).clone(),
}),
(Some(decision), None) => {
changes.push(SnapshotChange::DecisionRemoved {
case: case_name.to_owned(),
decision: (*decision).clone(),
});
}
(Some(before), Some(after)) if before != after => {
changes.push(SnapshotChange::DecisionChanged {
case: case_name.to_owned(),
before: (*before).clone(),
after: (*after).clone(),
});
}
(Some(_), Some(_)) => unchanged_decisions += 1,
(None, None) => unreachable!("identity came from one snapshot"),
}
}
}
(None, None) => unreachable!("case name came from one snapshot"),
}
}
SnapshotDiff {
schema_version: SNAPSHOT_DIFF_SCHEMA_VERSION.to_owned(),
baseline_module: baseline.module.clone(),
current_module: current.module.clone(),
unchanged_decisions,
changes,
}
}
fn decisions_by_identity(
decisions: &[DecisionObservation],
) -> BTreeMap<(String, usize), &DecisionObservation> {
decisions
.iter()
.map(|decision| ((decision.query.clone(), decision.occurrence), decision))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SourceFile, compile_source};
fn snapshot(source: &str) -> DecisionSnapshot {
let output = compile_source(SourceFile::new("snapshot.tes", source));
assert!(output.program.is_some(), "{:?}", output.diagnostics);
capture_decision_snapshot(output.program.as_ref().unwrap(), None).unwrap()
}
#[test]
fn capture_is_sorted_and_omits_non_semantic_rule_details() {
let captured = snapshot(
r"mod sample
record Subject:
flag: Bool
dec result(s Subject) -> Bool
policy::result @result policy:
Enabled subjects receive a positive result.
rule yes(s Subject):
s.flag => result(s) = true
test z:
let s = Subject { flag: true }
expect result(s) = true
test a:
let s = Subject { flag: false }
expect result(s) = true
",
);
assert_eq!(captured.schema_version, DECISION_SNAPSHOT_SCHEMA_VERSION);
assert_eq!(captured.cases[0].case, "a");
assert!(matches!(
captured.cases[0].decisions[0].result,
SnapshotDecisionStatus::Undefined
));
assert_eq!(captured.decision_count(), 2);
}
#[test]
fn diff_reports_semantic_changes_by_query_identity() {
let before = snapshot(
r"mod sample
record Subject:
flag: Bool
dec result(s Subject) -> Bool
policy::result @result policy:
The result value before the change.
rule value(s Subject):
=> result(s) = false
test example:
let s = Subject { flag: true }
expect result(s) = true
",
);
let after = snapshot(
r"mod sample
record Subject:
flag: Bool
dec result(s Subject) -> Bool
policy::result @result policy:
The result value after the change.
rule value(s Subject):
=> result(s) = true
test example:
let s = Subject { flag: true }
expect result(s) = true
",
);
let diff = diff_decision_snapshots(&before, &after);
assert_eq!(diff.changes.len(), 1);
assert!(matches!(
diff.changes[0],
SnapshotChange::DecisionChanged { .. }
));
}
}