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::{DateSystem, 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}
404
405/// Coerce temporal/serial values to the manifest-declared type at the port boundary.
406///
407/// SheetPort is a versioned protocol: a port declared `number`/`integer` must keep
408/// receiving numeric serials regardless of the engine's `TemporalEgress` policy, and a
409/// port declared `date`/`datetime` must receive native temporals even when the engine
410/// egresses raw serials. The manifest is the contract; engine egress is a default that
411/// applies only where the manifest does not speak.
412pub(crate) fn coerce_port_value_to_declared(
413    binding: &PortBinding,
414    value: PortValue,
415    date_system: DateSystem,
416) -> PortValue {
417    match (&binding.kind, value) {
418        (BoundPort::Scalar(scalar), PortValue::Scalar(v)) => PortValue::Scalar(
419            coerce_literal_to_declared(scalar.value_type, v, date_system),
420        ),
421        (BoundPort::Record(record), PortValue::Record(mut map)) => {
422            for (field_name, field_binding) in &record.fields {
423                if let Some(v) = map.remove(field_name) {
424                    map.insert(
425                        field_name.clone(),
426                        coerce_literal_to_declared(field_binding.value_type, v, date_system),
427                    );
428                }
429            }
430            PortValue::Record(map)
431        }
432        (BoundPort::Range(range), PortValue::Range(rows)) => PortValue::Range(
433            rows.into_iter()
434                .map(|row| {
435                    row.into_iter()
436                        .map(|v| coerce_literal_to_declared(range.cell_type, v, date_system))
437                        .collect()
438                })
439                .collect(),
440        ),
441        (BoundPort::Table(table), PortValue::Table(mut tv)) => {
442            for row in &mut tv.rows {
443                for column in &table.columns {
444                    if let Some(v) = row.values.remove(&column.name) {
445                        row.values.insert(
446                            column.name.clone(),
447                            coerce_literal_to_declared(column.value_type, v, date_system),
448                        );
449                    }
450                }
451            }
452            PortValue::Table(tv)
453        }
454        (_, value) => value,
455    }
456}
457
458fn coerce_literal_to_declared(
459    value_type: ValueType,
460    value: LiteralValue,
461    system: DateSystem,
462) -> LiteralValue {
463    use formualizer_common::{
464        date_to_serial_for, datetime_to_serial_for, time_to_fraction, try_serial_to_date_for,
465        try_serial_to_datetime_for,
466    };
467    match value_type {
468        ValueType::Number | ValueType::Integer => match value {
469            LiteralValue::Date(d) => LiteralValue::Number(date_to_serial_for(system, &d)),
470            LiteralValue::DateTime(dt) => LiteralValue::Number(datetime_to_serial_for(system, &dt)),
471            LiteralValue::Time(t) => LiteralValue::Number(time_to_fraction(&t)),
472            LiteralValue::Duration(d) => LiteralValue::Number(
473                d.num_nanoseconds()
474                    .map(|n| n as f64 / (86_400.0 * 1_000_000_000.0))
475                    .unwrap_or_else(|| d.num_seconds() as f64 / 86_400.0),
476            ),
477            other => other,
478        },
479        ValueType::Date => match value {
480            LiteralValue::Number(n) => try_serial_to_date_for(system, n)
481                .map(LiteralValue::Date)
482                .unwrap_or(LiteralValue::Number(n)),
483            other => other,
484        },
485        ValueType::Datetime => match value {
486            LiteralValue::Number(n) => try_serial_to_datetime_for(system, n)
487                .map(LiteralValue::DateTime)
488                .unwrap_or(LiteralValue::Number(n)),
489            other => other,
490        },
491        ValueType::String | ValueType::Boolean => value,
492    }
493}
494
495#[cfg(test)]
496mod coerce_tests {
497    use super::*;
498    use chrono::{NaiveDate, NaiveDateTime};
499
500    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
501        NaiveDate::from_ymd_opt(y, m, day).unwrap()
502    }
503
504    #[test]
505    fn numeric_port_receives_serial_from_native_date() {
506        let v = coerce_literal_to_declared(
507            ValueType::Number,
508            LiteralValue::Date(d(2026, 8, 20)),
509            DateSystem::Excel1900,
510        );
511        let LiteralValue::Number(serial) = v else {
512            panic!("expected serial, got {v:?}");
513        };
514        assert_eq!(
515            formualizer_common::try_serial_to_date_for(DateSystem::Excel1900, serial).unwrap(),
516            d(2026, 8, 20)
517        );
518    }
519
520    #[test]
521    fn numeric_port_receives_serial_from_native_datetime() {
522        let dt = NaiveDateTime::new(
523            d(2026, 8, 20),
524            chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap(),
525        );
526        let v = coerce_literal_to_declared(
527            ValueType::Number,
528            LiteralValue::DateTime(dt),
529            DateSystem::Excel1900,
530        );
531        let LiteralValue::Number(serial) = v else {
532            panic!("expected serial, got {v:?}");
533        };
534        assert!(
535            (serial.fract() - 0.25).abs() < 1e-9,
536            "6:00 is a quarter day, got {serial}"
537        );
538    }
539
540    #[test]
541    fn date_port_receives_native_from_serial() {
542        let serial = formualizer_common::date_to_serial_for(DateSystem::Excel1900, &d(2026, 8, 20));
543        let v = coerce_literal_to_declared(
544            ValueType::Date,
545            LiteralValue::Number(serial),
546            DateSystem::Excel1900,
547        );
548        assert_eq!(v, LiteralValue::Date(d(2026, 8, 20)));
549    }
550
551    #[test]
552    fn non_temporal_values_pass_through_unchanged() {
553        for vt in [ValueType::Number, ValueType::Date, ValueType::String] {
554            assert_eq!(
555                coerce_literal_to_declared(
556                    vt,
557                    LiteralValue::Text("x".into()),
558                    DateSystem::Excel1900
559                ),
560                LiteralValue::Text("x".into())
561            );
562        }
563        assert_eq!(
564            coerce_literal_to_declared(
565                ValueType::Number,
566                LiteralValue::Int(3),
567                DateSystem::Excel1900
568            ),
569            LiteralValue::Int(3)
570        );
571    }
572}