1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum MatchMode {
9 Scalar,
10 Set,
11 Ordered,
12}
13
14#[derive(Debug, Clone, Copy, Deserialize)]
15pub struct Tolerance {
16 #[serde(default)]
17 pub numeric: f64,
18}
19
20impl Default for Tolerance {
21 fn default() -> Self {
22 Tolerance { numeric: 0.0 }
23 }
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct Table {
28 #[serde(default)]
29 pub columns: Vec<String>,
30 #[serde(default)]
31 pub rows: Vec<Vec<Value>>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct CompareRequest {
36 #[serde(rename = "match")]
37 pub match_mode: MatchMode,
38 #[serde(default)]
39 pub tolerance: Tolerance,
40 pub expected: Table,
41 pub actual: Table,
42}
43
44#[derive(Debug, Serialize, PartialEq)]
45pub struct CompareResult {
46 pub pass: bool,
47 pub reason: String,
48}
49
50impl CompareResult {
51 fn pass() -> Self {
52 CompareResult {
53 pass: true,
54 reason: "match".to_string(),
55 }
56 }
57
58 fn fail(reason: impl Into<String>) -> Self {
59 CompareResult {
60 pass: false,
61 reason: reason.into(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
67enum NormCell {
68 Null,
69 Num(i64),
70 Str(String),
71}
72
73fn as_number(v: &Value) -> Option<f64> {
74 match v {
75 Value::Number(n) => n.as_f64(),
76 Value::String(s) => s.trim().parse::<f64>().ok(),
77 _ => None,
78 }
79}
80
81fn display_string(v: &Value) -> String {
82 match v {
83 Value::String(s) => s.clone(),
84 Value::Bool(b) => b.to_string(),
85 Value::Number(n) => n.to_string(),
86 other => other.to_string(),
87 }
88}
89
90pub fn cells_equal(a: &Value, b: &Value, tolerance: f64) -> bool {
94 let a_null = a.is_null();
95 let b_null = b.is_null();
96 if a_null || b_null {
97 return a_null && b_null;
98 }
99 if let (Some(na), Some(nb)) = (as_number(a), as_number(b)) {
100 return (na - nb).abs() <= tolerance;
101 }
102 display_string(a).trim() == display_string(b).trim()
103}
104
105fn decimals_for_tolerance(tolerance: f64) -> i32 {
108 if tolerance <= 0.0 {
109 return 9;
110 }
111 (-tolerance.log10()).ceil().max(0.0) as i32
112}
113
114fn round_to_grid(value: f64, decimals: i32) -> i64 {
115 let multiplier = 10f64.powi(decimals);
116 (value * multiplier).round() as i64
117}
118
119fn normalize_cell(v: &Value, decimals: i32) -> NormCell {
120 if v.is_null() {
121 return NormCell::Null;
122 }
123 if let Some(n) = as_number(v) {
124 return NormCell::Num(round_to_grid(n, decimals));
125 }
126 NormCell::Str(display_string(v).trim().to_string())
127}
128
129fn normalize_row(row: &[Value], decimals: i32) -> Vec<NormCell> {
132 let mut cells: Vec<NormCell> = row.iter().map(|v| normalize_cell(v, decimals)).collect();
133 cells.sort();
134 cells
135}
136
137fn non_null_cells(table: &Table) -> Vec<&Value> {
138 table
139 .rows
140 .iter()
141 .flat_map(|row| row.iter())
142 .filter(|v| !v.is_null())
143 .collect()
144}
145
146fn compare_scalar(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
147 if expected.rows.len() != 1 || expected.rows[0].len() != 1 {
148 return CompareResult::fail(format!(
149 "scalar match requires expected to be 1x1, got {} row(s) x {} col(s)",
150 expected.rows.len(),
151 expected.rows.first().map_or(0, |r| r.len())
152 ));
153 }
154 let expected_cell = &expected.rows[0][0];
155
156 let actual_cell = if actual.rows.len() == 1 && actual.rows[0].len() == 1 {
157 &actual.rows[0][0]
158 } else {
159 let cells = non_null_cells(actual);
160 match cells.len() {
161 0 => {
162 return CompareResult::fail(format!(
163 "scalar match requires actual to reduce to one non-null cell, but actual has {} row(s) x up to {} col(s) with no non-null values",
164 actual.rows.len(),
165 actual.rows.iter().map(|r| r.len()).max().unwrap_or(0)
166 ));
167 }
168 1 => cells[0],
169 n => {
170 return CompareResult::fail(format!(
171 "scalar match requires actual to reduce to one non-null cell, but found {} non-null cells across {} row(s)",
172 n,
173 actual.rows.len()
174 ));
175 }
176 }
177 };
178
179 if cells_equal(expected_cell, actual_cell, tolerance) {
180 CompareResult::pass()
181 } else {
182 CompareResult::fail(format!(
183 "scalar mismatch: expected {} but got {}",
184 display_string(expected_cell),
185 display_string(actual_cell)
186 ))
187 }
188}
189
190fn row_multiset(table: &Table, decimals: i32) -> BTreeMap<Vec<NormCell>, (usize, Vec<Value>)> {
191 let mut map: BTreeMap<Vec<NormCell>, (usize, Vec<Value>)> = BTreeMap::new();
192 for row in &table.rows {
193 let key = normalize_row(row, decimals);
194 let entry = map.entry(key).or_insert((0, row.clone()));
195 entry.0 += 1;
196 }
197 map
198}
199
200fn compare_set(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
201 let decimals = decimals_for_tolerance(tolerance);
202 let expected_map = row_multiset(expected, decimals);
203 let actual_map = row_multiset(actual, decimals);
204
205 let counts_equal = expected_map.len() == actual_map.len()
206 && expected_map
207 .iter()
208 .all(|(key, (count, _))| actual_map.get(key).map(|(c, _)| c) == Some(count));
209 if counts_equal {
210 return CompareResult::pass();
211 }
212
213 let mut keys: Vec<&Vec<NormCell>> = expected_map.keys().chain(actual_map.keys()).collect();
214 keys.sort();
215 keys.dedup();
216
217 for key in keys {
218 let expected_count = expected_map.get(key).map_or(0, |(c, _)| *c);
219 let actual_count = actual_map.get(key).map_or(0, |(c, _)| *c);
220 if expected_count != actual_count {
221 let example = expected_map
222 .get(key)
223 .or_else(|| actual_map.get(key))
224 .map(|(_, row)| row.clone())
225 .unwrap_or_default();
226 return CompareResult::fail(format!(
227 "set mismatch: expected {} row(s) total, actual {} row(s) total; row {:?} appears {} time(s) in expected but {} time(s) in actual",
228 expected.rows.len(),
229 actual.rows.len(),
230 example,
231 expected_count,
232 actual_count
233 ));
234 }
235 }
236
237 CompareResult::fail("set mismatch: multisets differ")
238}
239
240fn compare_ordered(expected: &Table, actual: &Table, tolerance: f64) -> CompareResult {
241 if expected.rows.len() != actual.rows.len() {
242 return CompareResult::fail(format!(
243 "ordered mismatch: row count differs (expected {}, actual {})",
244 expected.rows.len(),
245 actual.rows.len()
246 ));
247 }
248
249 let decimals = decimals_for_tolerance(tolerance);
250 for (i, (expected_row, actual_row)) in expected.rows.iter().zip(actual.rows.iter()).enumerate()
251 {
252 let expected_norm = normalize_row(expected_row, decimals);
253 let actual_norm = normalize_row(actual_row, decimals);
254 if expected_norm != actual_norm {
255 return CompareResult::fail(format!(
256 "ordered mismatch at row index {}: expected {:?}, actual {:?}",
257 i, expected_row, actual_row
258 ));
259 }
260 }
261
262 CompareResult::pass()
263}
264
265pub fn compare(request: &CompareRequest) -> CompareResult {
266 match request.match_mode {
267 MatchMode::Scalar => compare_scalar(
268 &request.expected,
269 &request.actual,
270 request.tolerance.numeric,
271 ),
272 MatchMode::Set => compare_set(
273 &request.expected,
274 &request.actual,
275 request.tolerance.numeric,
276 ),
277 MatchMode::Ordered => compare_ordered(
278 &request.expected,
279 &request.actual,
280 request.tolerance.numeric,
281 ),
282 }
283}