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#[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}