use crate::export::{global_to_json, local_to_json};
use crate::import::ImportError;
use crate::runner::{LeanRunner, LeanRunnerError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use telltale_theory::projection::{project, ProjectionError};
use telltale_types::GlobalType;
use thiserror::Error;
#[path = "equivalence_golden_cases.rs"]
mod golden;
#[derive(Debug, Error)]
pub enum EquivalenceError {
#[error("Golden file not found: {0}")]
GoldenNotFound(PathBuf),
#[error("Failed to parse golden file: {0}")]
ParseError(String),
#[error("Lean runner error: {0}")]
LeanError(#[from] LeanRunnerError),
#[error("Import error: {0}")]
ImportError(#[from] ImportError),
#[error("Projection error: {0}")]
ProjectionError(#[from] ProjectionError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Lean runner not available - build with: cd lean && lake build telltale_validator")]
LeanNotAvailable,
#[error("Golden file drift detected: {path}")]
GoldenDrift { path: PathBuf },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquivalenceResult {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub equivalent: bool,
pub rust_output: Value,
pub expected_output: Value,
pub diff: Option<String>,
pub role: String,
}
impl EquivalenceResult {
pub fn success(role: impl Into<String>, output: Value) -> Self {
Self {
schema_version: crate::schema::canonical_schema_version(),
equivalent: true,
rust_output: output.clone(),
expected_output: output,
diff: None,
role: role.into(),
}
}
pub fn failure(
role: impl Into<String>,
rust_output: Value,
expected_output: Value,
diff: String,
) -> Self {
Self {
schema_version: crate::schema::canonical_schema_version(),
equivalent: false,
rust_output,
expected_output,
diff: Some(diff),
role: role.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoldenBundle {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub input: Value,
pub projections: HashMap<String, Value>,
pub coherence: Option<CoherenceBundle>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoherenceBundle {
#[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
pub schema_version: String,
pub linear: bool,
pub size: bool,
pub action: bool,
pub uniq_labels: bool,
pub projectable: bool,
pub good: bool,
pub is_coherent: bool,
}
#[derive(Debug, Clone)]
pub struct EquivalenceConfig {
pub golden_dir: PathBuf,
pub strict: bool,
}
impl Default for EquivalenceConfig {
fn default() -> Self {
Self {
golden_dir: PathBuf::from("golden"),
strict: false,
}
}
}
pub struct EquivalenceChecker {
config: EquivalenceConfig,
runner: Option<LeanRunner>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strictness {
Strict,
Lenient,
}
impl Strictness {
const fn is_strict(self) -> bool {
matches!(self, Self::Strict)
}
}
impl From<bool> for Strictness {
fn from(value: bool) -> Self {
if value {
Self::Strict
} else {
Self::Lenient
}
}
}
impl EquivalenceChecker {
#[must_use]
pub fn with_golden_dir(dir: impl AsRef<Path>) -> Self {
Self {
config: EquivalenceConfig {
golden_dir: dir.as_ref().to_path_buf(),
..Default::default()
},
runner: None,
}
}
#[must_use]
pub fn with_golden_dir_strict(
dir: impl AsRef<Path>,
strictness: impl Into<Strictness>,
) -> Self {
Self {
config: EquivalenceConfig {
golden_dir: dir.as_ref().to_path_buf(),
strict: strictness.into().is_strict(),
},
runner: None,
}
}
#[must_use]
pub fn with_strict_mode(mut self, strictness: impl Into<Strictness>) -> Self {
self.config.strict = strictness.into().is_strict();
self
}
pub fn with_lean(golden_dir: impl AsRef<Path>) -> Result<Self, EquivalenceError> {
let runner = LeanRunner::new()?;
Ok(Self {
config: EquivalenceConfig {
golden_dir: golden_dir.as_ref().to_path_buf(),
..Default::default()
},
runner: Some(runner),
})
}
pub fn try_with_lean(golden_dir: impl AsRef<Path>) -> Option<Self> {
Self::with_lean(golden_dir).ok()
}
pub fn has_lean(&self) -> bool {
self.runner.is_some()
}
pub fn golden_dir(&self) -> &Path {
&self.config.golden_dir
}
fn parse_projections_map(
lean_output: &Value,
) -> Result<HashMap<String, Value>, EquivalenceError> {
crate::projection_payload::parse_projections_field(lean_output)
.map_err(EquivalenceError::ParseError)
}
fn ensure_projection_roles(
expected_roles: &[String],
projections: &HashMap<String, Value>,
) -> Result<(), EquivalenceError> {
let expected: BTreeSet<String> = expected_roles.iter().cloned().collect();
let actual: BTreeSet<String> = projections.keys().cloned().collect();
let missing: Vec<String> = expected.difference(&actual).cloned().collect();
let unexpected: Vec<String> = actual.difference(&expected).cloned().collect();
if missing.is_empty() && unexpected.is_empty() {
return Ok(());
}
Err(EquivalenceError::ParseError(format!(
"projection role-set mismatch: missing={missing:?}, unexpected={unexpected:?}"
)))
}
pub fn check_projection_against_lean(
&self,
global: &GlobalType,
role: &str,
) -> Result<EquivalenceResult, EquivalenceError> {
let runner = self
.runner
.as_ref()
.ok_or(EquivalenceError::LeanNotAvailable)?;
let global_json = global_to_json(global);
let lean_output = runner.export_projection(&global_json, role)?;
if lean_output["success"].as_bool() != Some(true) {
let err = lean_output["error"].to_string();
return Err(EquivalenceError::ParseError(format!(
"Lean projection failed: {}",
err
)));
}
let expected = lean_output
.get("projection")
.or_else(|| lean_output.get("result"))
.ok_or_else(|| {
EquivalenceError::ParseError("Missing projection in Lean output".into())
})?
.clone();
let rust_local = project(global, role)?;
let rust_output = local_to_json(&rust_local);
self.compare_local_types(role, &rust_output, &expected)
}
pub fn check_all_projections_against_lean(
&self,
global: &GlobalType,
) -> Result<Vec<EquivalenceResult>, EquivalenceError> {
let runner = self
.runner
.as_ref()
.ok_or(EquivalenceError::LeanNotAvailable)?;
let global_json = global_to_json(global);
let lean_output = runner.export_all_projections(&global_json)?;
if lean_output["success"].as_bool() != Some(true) {
let err = lean_output["error"].to_string();
return Err(EquivalenceError::ParseError(format!(
"Lean projections failed: {}",
err
)));
}
let mut results = Vec::new();
let projections = Self::parse_projections_map(&lean_output)?;
Self::ensure_projection_roles(&global.roles(), &projections)?;
for (role, expected) in projections {
let rust_local = project(global, &role)?;
let rust_output = local_to_json(&rust_local);
let result = self.compare_local_types(&role, &rust_output, &expected)?;
results.push(result);
}
Ok(results)
}
fn compare_local_types(
&self,
role: &str,
rust_output: &Value,
expected: &Value,
) -> Result<EquivalenceResult, EquivalenceError> {
let equivalent = if self.config.strict {
serde_json::to_string(rust_output).ok() == serde_json::to_string(expected).ok()
} else {
self.json_structurally_equal(rust_output, expected)
};
if equivalent {
Ok(EquivalenceResult::success(role, rust_output.clone()))
} else {
let diff = self.compute_diff(rust_output, expected);
Ok(EquivalenceResult::failure(
role,
rust_output.clone(),
expected.clone(),
diff,
))
}
}
#[allow(clippy::only_used_in_recursion)]
fn json_structurally_equal(&self, a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Null, Value::Null) => true,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Number(a), Value::Number(b)) => a == b,
(Value::String(a), Value::String(b)) => a == b,
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(x, y)| self.json_structurally_equal(x, y))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter().all(|(k, v)| {
b.get(k)
.map(|bv| self.json_structurally_equal(v, bv))
.unwrap_or(false)
})
}
_ => false,
}
}
fn compute_diff(&self, rust: &Value, expected: &Value) -> String {
format!(
"Rust:\n{}\n\nExpected (Lean):\n{}",
serde_json::to_string_pretty(rust).unwrap_or_default(),
serde_json::to_string_pretty(expected).unwrap_or_default()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_equivalence_result_success() {
let result = EquivalenceResult::success("Alice", serde_json::json!({"kind": "end"}));
assert!(result.equivalent);
assert!(result.diff.is_none());
assert_eq!(result.role, "Alice");
}
#[test]
fn test_equivalence_result_failure() {
let result = EquivalenceResult::failure(
"Bob",
serde_json::json!({"kind": "end"}),
serde_json::json!({"kind": "send"}),
"Mismatch".to_string(),
);
assert!(!result.equivalent);
assert!(result.diff.is_some());
}
#[test]
fn test_json_structural_equality() {
let checker = EquivalenceChecker::with_golden_dir("golden");
let a = serde_json::json!({"kind": "end"});
let b = serde_json::json!({"kind": "end"});
assert!(checker.json_structurally_equal(&a, &b));
let c = serde_json::json!({"kind": "send"});
assert!(!checker.json_structurally_equal(&a, &c));
let d = serde_json::json!({
"kind": "comm",
"branches": [{"label": "msg"}]
});
let e = serde_json::json!({
"kind": "comm",
"branches": [{"label": "msg"}]
});
assert!(checker.json_structurally_equal(&d, &e));
}
#[test]
fn test_checker_has_lean() {
let checker = EquivalenceChecker::with_golden_dir("golden");
assert!(!checker.has_lean());
if LeanRunner::is_available() {
let with_lean = EquivalenceChecker::with_lean("golden").unwrap();
assert!(with_lean.has_lean());
}
}
#[test]
fn test_strict_mode_is_wired_into_comparison() {
let non_strict = EquivalenceChecker::with_golden_dir_strict("golden", false);
let strict = EquivalenceChecker::with_golden_dir_strict("golden", true);
let left: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("left json");
let right: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("right json");
let mismatch: Value = serde_json::from_str(r#"{"a":1,"b":3}"#).expect("mismatch json");
let strict_result = strict
.compare_local_types("A", &left, &right)
.expect("strict comparison result");
let strict_mismatch = strict
.compare_local_types("A", &left, &mismatch)
.expect("strict mismatch comparison");
let non_strict_result = non_strict
.compare_local_types("A", &left, &right)
.expect("non-strict comparison result");
let non_strict_mismatch = non_strict
.compare_local_types("A", &left, &mismatch)
.expect("non-strict mismatch comparison");
assert!(strict_result.equivalent);
assert!(non_strict_result.equivalent);
assert!(!strict_mismatch.equivalent);
assert!(!non_strict_mismatch.equivalent);
}
#[test]
fn test_projection_role_set_check_rejects_missing_roles() {
let expected = vec!["A".to_string(), "B".to_string()];
let mut projections = HashMap::new();
projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
let err = EquivalenceChecker::ensure_projection_roles(&expected, &projections)
.expect_err("must reject missing role");
assert!(matches!(err, EquivalenceError::ParseError(_)));
}
#[test]
fn test_projection_role_set_check_rejects_unexpected_roles() {
let expected = vec!["A".to_string()];
let mut projections = HashMap::new();
projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
projections.insert("B".to_string(), serde_json::json!({"kind": "end"}));
let err = EquivalenceChecker::ensure_projection_roles(&expected, &projections)
.expect_err("must reject unexpected role");
assert!(matches!(err, EquivalenceError::ParseError(_)));
}
#[test]
fn test_projection_role_set_check_accepts_exact_match() {
let expected = vec!["A".to_string(), "B".to_string()];
let mut projections = HashMap::new();
projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
projections.insert("B".to_string(), serde_json::json!({"kind": "end"}));
EquivalenceChecker::ensure_projection_roles(&expected, &projections)
.expect("must accept exact role set");
}
}