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#[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#[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 }
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
405pub(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}