Skip to main content

varar_core/
cell_diff.rs

1//! Table row/cell comparison — port of `cell-diff.ts` / `CellDiff.java`.
2//! `Object` + `instanceof Map`/`List` duck-typing becomes matching on [`Value`].
3
4use crate::ast::Table;
5use crate::error::StepError;
6use crate::span::Span;
7use crate::value::Value;
8
9/// The verdict for one comparison of one CELL — the atomic value a sensor checks
10/// against the document (a table cell, a header-bound row's cell, or a value
11/// captured from a paragraph). `column` labels it. Expected vs actual, plus raw values and
12/// whether a parameter-type `format` produced `actual` (inline-parameter path).
13#[derive(Clone, Debug, PartialEq)]
14pub struct CellDiff {
15    pub column: String,
16    pub span: Span,
17    pub expected: String,
18    pub actual: String,
19    pub ok: bool,
20    pub expected_value: Option<Value>,
21    pub actual_value: Option<Value>,
22    pub formatted: bool,
23}
24
25impl CellDiff {
26    /// The five-component form (row/table paths): raw values `None`, not formatted.
27    pub fn new(
28        column: impl Into<String>,
29        span: Span,
30        expected: impl Into<String>,
31        actual: impl Into<String>,
32        ok: bool,
33    ) -> CellDiff {
34        CellDiff {
35            column: column.into(),
36            span,
37            expected: expected.into(),
38            actual: actual.into(),
39            ok,
40            expected_value: None,
41            actual_value: None,
42            formatted: false,
43        }
44    }
45}
46
47/// One checked column of one header-bound row.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct RowCheck {
50    pub column: String,
51    pub value: String,
52    pub span: Span,
53}
54
55impl RowCheck {
56    pub fn new(column: impl Into<String>, value: impl Into<String>, span: Span) -> RowCheck {
57        RowCheck {
58            column: column.into(),
59            value: value.into(),
60            span,
61        }
62    }
63}
64
65/// Display rules 2–4 of the mismatch-rendering chain: a string as-is, anything
66/// else a best-effort stringification.
67pub fn render_cell_value(value: &Value) -> String {
68    match value {
69        Value::String(s) => s.clone(),
70        Value::Int(i) => i.to_string(),
71        Value::Float(d) => format!("{d}"),
72        Value::Bool(b) => b.to_string(),
73        Value::Null => "null".to_string(),
74        // Port-native fallback (deliberately outside conformance): a bundle that
75        // pins an object-valued actual must give the parameter type a `format`.
76        Value::List(_) | Value::Map(_) => format!("{value:?}"),
77    }
78}
79
80/// Compares a row step's returned map against the row's cells. Only columns
81/// present on `returned` are checked; a non-map/`None` return checks nothing.
82pub fn compare_row(returned: Option<&Value>, checks: &[RowCheck]) -> Vec<CellDiff> {
83    let Some(Value::Map(obj)) = returned else {
84        return Vec::new();
85    };
86    let mut diffs = Vec::new();
87    for check in checks {
88        let Some(value) = obj.get(&check.column) else {
89            continue;
90        };
91        let actual = render_cell_value(value);
92        let ok = actual == check.value;
93        diffs.push(CellDiff::new(
94            check.column.clone(),
95            check.span,
96            check.value.clone(),
97            actual,
98            ok,
99        ));
100    }
101    diffs
102}
103
104/// Compares a whole-table step's returned table against the input table. `None`
105/// checks nothing; type/shape problems return [`StepError::ReturnShape`].
106pub fn compare_table(returned: Option<&Value>, input: &Table) -> Result<Vec<CellDiff>, StepError> {
107    let rows = match returned {
108        None => return Ok(Vec::new()),
109        Some(Value::List(rows)) => rows,
110        Some(other) => {
111            return Err(StepError::ReturnShape(format!(
112                "expected a table (array of rows), got {}",
113                other.type_name()
114            )));
115        }
116    };
117    let columns = &input.header.cells;
118    let data_rows = &input.rows;
119    if rows.len() != data_rows.len() {
120        return Err(StepError::ReturnShape(format!(
121            "expected {} row(s), got {}",
122            data_rows.len(),
123            rows.len()
124        )));
125    }
126    let all_arrays = rows.iter().all(|r| matches!(r, Value::List(_)));
127    let all_records = rows.iter().all(|r| matches!(r, Value::Map(_)));
128    if !all_arrays && !all_records {
129        return Err(StepError::ReturnShape(
130            "table rows must be all arrays or all objects".to_string(),
131        ));
132    }
133
134    let mut diffs = Vec::new();
135    for (i, (data_row, ret)) in data_rows.iter().zip(rows).enumerate() {
136        if all_arrays {
137            if let Value::List(cells) = ret {
138                if cells.len() != columns.len() {
139                    return Err(StepError::ReturnShape(format!(
140                        "row {}: expected {} column(s), got {}",
141                        i,
142                        columns.len(),
143                        cells.len()
144                    )));
145                }
146            }
147        }
148        for (j, column) in columns.iter().enumerate() {
149            let actual_value: &Value = if all_arrays {
150                let Value::List(cells) = ret else {
151                    unreachable!()
152                };
153                &cells[j]
154            } else {
155                let Value::Map(rec) = ret else { unreachable!() };
156                match rec.get(column) {
157                    Some(v) => v,
158                    None => {
159                        return Err(StepError::ReturnShape(format!(
160                            "row {i}: missing column \"{column}\""
161                        )));
162                    }
163                }
164            };
165            let expected = data_row.cells.get(j).map_or("", |c| c.as_str());
166            let actual = render_cell_value(actual_value);
167            let span = data_row.cell_spans.get(j).copied().unwrap_or(data_row.span);
168            let ok = actual == expected;
169            diffs.push(CellDiff::new(column.clone(), span, expected, actual, ok));
170        }
171    }
172    Ok(diffs)
173}