Skip to main content

formualizer_sheetport/
validation.rs

1use crate::binding::{
2    BoundPort, PortBinding, RangeBinding, RecordBinding, RecordFieldBinding, ScalarBinding,
3    TableBinding,
4};
5use crate::value::{PortValue, TableRow, TableValue};
6use formualizer_common::LiteralValue;
7use regex::Regex;
8use serde_json::Value as JsonValue;
9use sheetport_spec::{Constraints, ValueType};
10use std::collections::BTreeMap;
11
12/// Detailed information about why a value failed validation.
13#[derive(Debug, Clone)]
14pub struct ConstraintViolation {
15    pub port: String,
16    pub path: String,
17    pub message: String,
18}
19
20impl ConstraintViolation {
21    pub fn new(
22        port: impl Into<String>,
23        path: impl Into<String>,
24        message: impl Into<String>,
25    ) -> Self {
26        Self {
27            port: port.into(),
28            path: path.into(),
29            message: message.into(),
30        }
31    }
32}
33
34/// Scope for validation. Partial is used when only updated values are provided.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ValidationScope {
37    Full,
38    Partial,
39}
40
41pub fn validate_port_value(
42    binding: &PortBinding,
43    value: &PortValue,
44    scope: ValidationScope,
45) -> Result<(), Vec<ConstraintViolation>> {
46    if !binding.required && value.is_empty() {
47        return Ok(());
48    }
49
50    let mut violations = Vec::new();
51    match (&binding.kind, value) {
52        (BoundPort::Scalar(scalar), PortValue::Scalar(lit)) => {
53            validate_scalar(binding, scalar, lit, scope, &mut violations);
54        }
55        (BoundPort::Record(record), PortValue::Record(map)) => {
56            validate_record(binding, record, map, scope, &mut violations);
57        }
58        (BoundPort::Range(range), PortValue::Range(rows)) => {
59            validate_range(binding, range, rows, &mut violations);
60        }
61        (BoundPort::Table(table), PortValue::Table(table_value)) => {
62            validate_table(binding, table, table_value, &mut violations);
63        }
64        _ => violations.push(ConstraintViolation::new(
65            &binding.id,
66            binding.id.clone(),
67            "value shape does not match manifest declaration",
68        )),
69    }
70
71    if violations.is_empty() {
72        Ok(())
73    } else {
74        Err(violations)
75    }
76}
77
78fn validate_scalar(
79    binding: &PortBinding,
80    scalar: &ScalarBinding,
81    value: &LiteralValue,
82    scope: ValidationScope,
83    violations: &mut Vec<ConstraintViolation>,
84) {
85    let path = binding.id.clone();
86    validate_literal(
87        &binding.id,
88        &path,
89        scalar.value_type,
90        binding.constraints.as_ref(),
91        value,
92        scope,
93        violations,
94    );
95}
96
97fn validate_record(
98    binding: &PortBinding,
99    record: &RecordBinding,
100    map: &BTreeMap<String, LiteralValue>,
101    scope: ValidationScope,
102    violations: &mut Vec<ConstraintViolation>,
103) {
104    match scope {
105        ValidationScope::Full => {
106            for (field_name, field_binding) in &record.fields {
107                let path = format!("{}.{}", binding.id, field_name);
108                match map.get(field_name) {
109                    Some(value) => validate_record_field(
110                        binding,
111                        field_binding,
112                        value,
113                        scope,
114                        &path,
115                        violations,
116                    ),
117                    None => violations.push(ConstraintViolation::new(
118                        &binding.id,
119                        path,
120                        "record field missing in resolved value",
121                    )),
122                }
123            }
124        }
125        ValidationScope::Partial => {
126            for (field_name, value) in map {
127                match record.fields.get(field_name) {
128                    Some(field_binding) => {
129                        let path = format!("{}.{}", binding.id, field_name);
130                        validate_record_field(
131                            binding,
132                            field_binding,
133                            value,
134                            scope,
135                            &path,
136                            violations,
137                        );
138                    }
139                    None => violations.push(ConstraintViolation::new(
140                        &binding.id,
141                        format!("{}.{}", binding.id, field_name),
142                        "record field is not declared in manifest",
143                    )),
144                }
145            }
146        }
147    }
148}
149
150fn validate_record_field(
151    binding: &PortBinding,
152    field_binding: &RecordFieldBinding,
153    value: &LiteralValue,
154    scope: ValidationScope,
155    path: &str,
156    violations: &mut Vec<ConstraintViolation>,
157) {
158    validate_literal(
159        &binding.id,
160        path,
161        field_binding.value_type,
162        field_binding.constraints.as_ref(),
163        value,
164        scope,
165        violations,
166    );
167}
168
169fn validate_range(
170    binding: &PortBinding,
171    range: &RangeBinding,
172    rows: &[Vec<LiteralValue>],
173    violations: &mut Vec<ConstraintViolation>,
174) {
175    for (row_idx, row) in rows.iter().enumerate() {
176        for (col_idx, cell) in row.iter().enumerate() {
177            let path = format!("{}[r{},c{}]", binding.id, row_idx + 1, col_idx + 1);
178            validate_literal(
179                &binding.id,
180                &path,
181                range.cell_type,
182                binding.constraints.as_ref(),
183                cell,
184                ValidationScope::Full,
185                violations,
186            );
187        }
188    }
189}
190
191fn validate_table(
192    binding: &PortBinding,
193    table: &TableBinding,
194    table_value: &TableValue,
195    violations: &mut Vec<ConstraintViolation>,
196) {
197    for (row_idx, row) in table_value.rows.iter().enumerate() {
198        validate_table_row(binding, table, row, row_idx, violations);
199    }
200}
201
202fn validate_table_row(
203    binding: &PortBinding,
204    table: &TableBinding,
205    row: &TableRow,
206    row_idx: usize,
207    violations: &mut Vec<ConstraintViolation>,
208) {
209    for column_name in row.values.keys() {
210        if !table.columns.iter().any(|c| c.name == *column_name) {
211            violations.push(ConstraintViolation::new(
212                &binding.id,
213                format!("{}[{}].{}", binding.id, row_idx, column_name),
214                "column is not defined in manifest",
215            ));
216        }
217    }
218
219    for column in &table.columns {
220        let path = format!("{}[{}].{}", binding.id, row_idx, column.name);
221        match row.values.get(&column.name) {
222            Some(value) => validate_literal(
223                &binding.id,
224                &path,
225                column.value_type,
226                binding.constraints.as_ref(),
227                value,
228                ValidationScope::Full,
229                violations,
230            ),
231            None => violations.push(ConstraintViolation::new(
232                &binding.id,
233                path,
234                "table row missing column value",
235            )),
236        }
237    }
238}
239
240fn validate_literal(
241    port_id: &str,
242    path: &str,
243    value_type: ValueType,
244    constraints: Option<&Constraints>,
245    value: &LiteralValue,
246    scope: ValidationScope,
247    violations: &mut Vec<ConstraintViolation>,
248) {
249    if is_empty(value) {
250        let nullable = constraints.and_then(|c| c.nullable).unwrap_or(false);
251        if !nullable {
252            violations.push(ConstraintViolation::new(
253                port_id,
254                path.to_string(),
255                "value may not be empty",
256            ));
257        }
258        return;
259    }
260
261    if let Err(message) = ensure_type(value_type, value) {
262        violations.push(ConstraintViolation::new(port_id, path.to_string(), message));
263        return;
264    }
265
266    if let Some(constraints) = constraints
267        && let Err(message) = enforce_constraints(value_type, value, constraints)
268    {
269        violations.push(ConstraintViolation::new(port_id, path.to_string(), message));
270    }
271
272    if scope == ValidationScope::Partial && is_empty(value) {
273        // Already handled above; included for completeness.
274    }
275}
276
277fn ensure_type(value_type: ValueType, value: &LiteralValue) -> Result<(), String> {
278    match value_type {
279        ValueType::String => {
280            if matches!(value, LiteralValue::Text(_)) {
281                Ok(())
282            } else {
283                Err("expected string value".into())
284            }
285        }
286        ValueType::Number => {
287            if matches!(value, LiteralValue::Number(_) | LiteralValue::Int(_)) {
288                Ok(())
289            } else {
290                Err("expected numeric value".into())
291            }
292        }
293        ValueType::Integer => match value {
294            LiteralValue::Int(_) => Ok(()),
295            LiteralValue::Number(n) if (*n - n.trunc()).abs() < f64::EPSILON => Ok(()),
296            _ => Err("expected integer value".into()),
297        },
298        ValueType::Boolean => {
299            if matches!(value, LiteralValue::Boolean(_)) {
300                Ok(())
301            } else {
302                Err("expected boolean value".into())
303            }
304        }
305        ValueType::Date => {
306            if matches!(value, LiteralValue::Date(_) | LiteralValue::DateTime(_)) {
307                Ok(())
308            } else {
309                Err("expected date value".into())
310            }
311        }
312        ValueType::Datetime => {
313            if matches!(value, LiteralValue::DateTime(_)) {
314                Ok(())
315            } else {
316                Err("expected datetime value".into())
317            }
318        }
319    }
320}
321
322fn enforce_constraints(
323    value_type: ValueType,
324    value: &LiteralValue,
325    constraints: &Constraints,
326) -> Result<(), String> {
327    if let Some(min) = constraints.min {
328        let v = to_f64(value)
329            .ok_or_else(|| "value must be numeric to apply `min` constraint".to_string())?;
330        if v < min {
331            return Err(format!("value {v} is below minimum {min}"));
332        }
333    }
334
335    if let Some(max) = constraints.max {
336        let v = to_f64(value)
337            .ok_or_else(|| "value must be numeric to apply `max` constraint".to_string())?;
338        if v > max {
339            return Err(format!("value {v} exceeds maximum {max}"));
340        }
341    }
342
343    if let Some(pattern) = &constraints.pattern {
344        let string = to_string_value(value, value_type)
345            .ok_or_else(|| "value must be a string to apply `pattern` constraint".to_string())?;
346        let regex = Regex::new(pattern).map_err(|err| format!("invalid regex pattern: {err}"))?;
347        if !regex.is_match(&string) {
348            return Err(format!(
349                "value `{string}` does not match pattern `{pattern}`"
350            ));
351        }
352    }
353
354    if let Some(enum_values) = &constraints.r#enum {
355        let literal_json = literal_to_json(value)
356            .ok_or_else(|| "value cannot be compared against enumeration entries".to_string())?;
357        if !enum_values
358            .iter()
359            .any(|candidate| candidate == &literal_json)
360        {
361            return Err("value is not an allowed enumeration option".to_string());
362        }
363    }
364
365    Ok(())
366}
367
368fn to_f64(value: &LiteralValue) -> Option<f64> {
369    match value {
370        LiteralValue::Number(n) => Some(*n),
371        LiteralValue::Int(i) => Some(*i as f64),
372        _ => None,
373    }
374}
375
376fn to_string_value(value: &LiteralValue, value_type: ValueType) -> Option<String> {
377    match (value_type, value) {
378        (_, LiteralValue::Text(s)) => Some(s.clone()),
379        (_, LiteralValue::Number(n)) => Some(n.to_string()),
380        (_, LiteralValue::Int(i)) => Some(i.to_string()),
381        (_, LiteralValue::Boolean(b)) => Some(b.to_string()),
382        (ValueType::Date, LiteralValue::Date(d)) => Some(d.to_string()),
383        (ValueType::Date, LiteralValue::DateTime(dt)) => Some(dt.date().to_string()),
384        (ValueType::Datetime, LiteralValue::DateTime(dt)) => Some(dt.to_string()),
385        _ => None,
386    }
387}
388
389fn literal_to_json(value: &LiteralValue) -> Option<JsonValue> {
390    match value {
391        LiteralValue::Text(s) => Some(JsonValue::String(s.clone())),
392        LiteralValue::Number(n) => serde_json::Number::from_f64(*n).map(JsonValue::Number),
393        LiteralValue::Int(i) => Some(JsonValue::Number((*i).into())),
394        LiteralValue::Boolean(b) => Some(JsonValue::Bool(*b)),
395        LiteralValue::Date(d) => Some(JsonValue::String(d.to_string())),
396        LiteralValue::DateTime(dt) => Some(JsonValue::String(dt.to_string())),
397        _ => None,
398    }
399}
400
401fn is_empty(value: &LiteralValue) -> bool {
402    matches!(value, LiteralValue::Empty)
403}