Skip to main content

molten_document/
validator.rs

1//! This module provides the core logic for validating a `Document` against its
2//! corresponding `FormDefinition`.
3//!
4//! It includes functions to perform comprehensive checks on document data,
5//! ensuring that all required fields are present, data types match, and
6//! specific constraints (like numerical ranges or selection options) are met.
7use crate::error::DocumentValidationError;
8use molten_core::document::Document;
9use molten_core::field::{FieldDefinition, FieldType};
10use molten_core::form::FormDefinition;
11use serde_json::Value;
12
13/// Validates a `Document` against its `FormDefinition`.
14///
15/// This function performs a comprehensive check, ensuring:
16/// 1. The `Document`'s `form_id` matches the `FormDefinition`'s ID.
17/// 2. All `required` fields as defined in the `FormDefinition` are present and not null in the `Document`.
18/// 3. Data types for each field in the `Document` match the `FieldType` specified in the `FormDefinition`.
19/// 4. Specific constraints (e.g., `min`/`max` for numbers, `options` for selects, ISO 8601 for dates) are met.
20///
21/// # Arguments
22/// * `doc` - A reference to the `Document` to be validated.
23/// * `form` - A reference to the `FormDefinition` to validate against.
24///
25/// # Returns
26/// A `Result` which is `Ok(())` if the document is valid, or `Err(Vec<DocumentValidationError>)`
27/// containing a list of all validation errors found.
28pub fn validate_document(
29    doc: &Document,
30    form: &FormDefinition,
31) -> Result<(), Vec<DocumentValidationError>> {
32    let mut errors = Vec::new();
33
34    // 1. Guard: Form ID mismatch
35    if doc.form_id != form.id() {
36        errors.push(DocumentValidationError::FormIdMismatch {
37            doc_form: doc.form_id.clone(),
38            def_id: form.id().to_string(),
39        });
40        return Err(errors);
41    }
42
43    // 2. Iterate over every field defined in the schema
44    for field_def in form.fields() {
45        let value = doc.get_value(field_def.id());
46
47        // Check Required
48        if field_def.is_required() && (value.is_none() || value.unwrap().is_null()) {
49            errors.push(DocumentValidationError::MissingRequiredField(
50                field_def.id().to_string(),
51            ));
52            continue; // Cannot validate type if missing
53        }
54
55        // If value exists, validate its content
56        if let Some(val) = value
57            && !val.is_null()
58        {
59            if let Err(e) = validate_value(val, field_def) {
60                errors.push(e);
61            }
62        }
63    }
64
65    if errors.is_empty() {
66        Ok(())
67    } else {
68        Err(errors)
69    }
70}
71
72/// Validates a single `serde_json::Value` against a `FieldDefinition`.
73///
74/// This private helper function checks the value's type and applies any constraints
75/// specified in the `FieldDefinition` (e.g., numerical ranges, valid selection options,
76/// or date format).
77///
78/// # Arguments
79/// * `value` - A reference to the `serde_json::Value` to validate.
80/// * `field` - A reference to the `FieldDefinition` to validate against.
81///
82/// # Returns
83/// A `Result` which is `Ok(())` if the value is valid according to the field definition,
84/// or `Err(DocumentValidationError)` if any validation rule is violated.
85fn validate_value(value: &Value, field: &FieldDefinition) -> Result<(), DocumentValidationError> {
86    match field.field_type() {
87        FieldType::Text | FieldType::TextArea => {
88            if !value.is_string() {
89                return Err(DocumentValidationError::InvalidType {
90                    field_id: field.id().to_string(),
91                    expected_type: "String".to_string(),
92                    got_type: get_json_type(value),
93                });
94            }
95            // Future: Add Regex validation here
96        }
97        FieldType::Number { min, max } => {
98            let num = value
99                .as_f64()
100                .ok_or_else(|| DocumentValidationError::InvalidType {
101                    field_id: field.id().to_string(),
102                    expected_type: "Number".to_string(),
103                    got_type: get_json_type(value),
104                })?;
105
106            if let Some(min_val) = min
107                && num < *min_val
108            {
109                return Err(DocumentValidationError::ValueTooLow {
110                    field_id: field.id().to_string(),
111                    value: num,
112                    min: *min_val,
113                });
114            }
115            if let Some(max_val) = max
116                && num > *max_val
117            {
118                return Err(DocumentValidationError::ValueTooHigh {
119                    field_id: field.id().to_string(),
120                    value: num,
121                    max: *max_val,
122                });
123            }
124        }
125        FieldType::Boolean => {
126            if !value.is_boolean() {
127                return Err(DocumentValidationError::InvalidType {
128                    field_id: field.id().to_string(),
129                    expected_type: "Boolean".to_string(),
130                    got_type: get_json_type(value),
131                });
132            }
133        }
134        FieldType::Select {
135            options,
136            allow_multiple,
137        } => {
138            if *allow_multiple {
139                // Expect an array of strings
140                let arr = value
141                    .as_array()
142                    .ok_or_else(|| DocumentValidationError::InvalidType {
143                        field_id: field.id().to_string(),
144                        expected_type: "Array".to_string(),
145                        got_type: get_json_type(value),
146                    })?;
147
148                for item in arr {
149                    let s = item
150                        .as_str()
151                        .ok_or_else(|| DocumentValidationError::InvalidType {
152                            field_id: field.id().to_string(),
153                            expected_type: "String".to_string(),
154                            got_type: get_json_type(item),
155                        })?;
156                    if !options.contains(&s.to_string()) {
157                        return Err(DocumentValidationError::InvalidSelection {
158                            field_id: field.id().to_string(),
159                            value: s.to_string(),
160                            allowed: options.clone(),
161                        });
162                    }
163                }
164            } else {
165                // Expect a single string
166                let s = value
167                    .as_str()
168                    .ok_or_else(|| DocumentValidationError::InvalidType {
169                        field_id: field.id().to_string(),
170                        expected_type: "String".to_string(),
171                        got_type: get_json_type(value),
172                    })?;
173
174                if !options.contains(&s.to_string()) {
175                    return Err(DocumentValidationError::InvalidSelection {
176                        field_id: field.id().to_string(),
177                        value: s.to_string(),
178                        allowed: options.clone(),
179                    });
180                }
181            }
182        }
183        FieldType::DateTime => {
184            let s = value
185                .as_str()
186                .ok_or_else(|| DocumentValidationError::InvalidType {
187                    field_id: field.id().to_string(),
188                    expected_type: "String (ISO 8601)".to_string(),
189                    got_type: get_json_type(value),
190                })?;
191
192            // Validate it parses as an ISO string
193            if chrono::DateTime::parse_from_rfc3339(s).is_err() {
194                return Err(DocumentValidationError::InvalidDateFormat {
195                    field_id: field.id().to_string(),
196                    value: s.to_string(),
197                });
198            }
199        }
200    }
201    Ok(())
202}
203
204/// Helper function to get a string representation of a `serde_json::Value`'s type.
205///
206/// # Arguments
207/// * `v` - A reference to the `serde_json::Value`.
208///
209/// # Returns
210/// A `String` representing the JSON type (e.g., "String", "Number", "Boolean", "Null", "Array", "Object").
211fn get_json_type(v: &Value) -> String {
212    match v {
213        Value::Null => "Null",
214        Value::Bool(_) => "Boolean",
215        Value::Number(_) => "Number",
216        Value::String(_) => "String",
217        Value::Array(_) => "Array",
218        Value::Object(_) => "Object",
219    }
220    .to_string()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use molten_core::document::Document;
227    use molten_core::field::{FieldBuilder, FieldType};
228    use molten_core::form::FormBuilder;
229    use serde_json::json;
230
231    fn create_test_form() -> FormDefinition {
232        FormBuilder::new("ticket", "Ticket")
233            .add_field(
234                FieldBuilder::new("title", "Title", FieldType::Text)
235                    .required(true)
236                    .build()
237                    .unwrap(),
238            )
239            .add_field(
240                FieldBuilder::new(
241                    "severity",
242                    "Severity",
243                    FieldType::Number {
244                        min: Some(1.0),
245                        max: Some(5.0),
246                    },
247                )
248                .build()
249                .unwrap(),
250            )
251            .add_field(
252                FieldBuilder::new(
253                    "status",
254                    "Status",
255                    FieldType::Select {
256                        options: vec!["Open".into(), "Closed".into()],
257                        allow_multiple: false,
258                    },
259                )
260                .build()
261                .unwrap(),
262            )
263            .build()
264            .unwrap()
265    }
266
267    #[test]
268    fn test_valid_document() {
269        let form = create_test_form();
270        let mut doc = Document::new("doc1", "ticket", "flow_ticket");
271        doc.set_value("title", json!("Server Down"));
272        doc.set_value("severity", json!(3));
273        doc.set_value("status", json!("Open"));
274
275        assert!(validate_document(&doc, &form).is_ok());
276    }
277
278    #[test]
279    fn test_missing_required() {
280        let form = create_test_form();
281        let doc = Document::new("doc1", "ticket", "flow_ticket");
282        // "title" is missing!
283
284        let res = validate_document(&doc, &form);
285        assert!(res.is_err());
286        let errs = res.unwrap_err();
287        assert!(matches!(
288            errs[0],
289            DocumentValidationError::MissingRequiredField(_)
290        ));
291    }
292
293    #[test]
294    fn test_type_mismatch() {
295        let form = create_test_form();
296        let mut doc = Document::new("doc1", "ticket", "flow_ticket");
297        doc.set_value("title", json!("Valid"));
298        doc.set_value("severity", json!("Five")); // String instead of Number
299
300        let res = validate_document(&doc, &form);
301        assert!(res.is_err());
302        assert!(format!("{:?}", res.unwrap_err()).contains("InvalidType"));
303    }
304
305    #[test]
306    fn test_number_range() {
307        let form = create_test_form();
308        let mut doc = Document::new("doc1", "ticket", "flow_ticket");
309        doc.set_value("title", json!("Valid"));
310        doc.set_value("severity", json!(10)); // Max is 5!
311
312        let res = validate_document(&doc, &form);
313        assert!(res.is_err());
314        assert!(matches!(
315            res.unwrap_err()[0],
316            DocumentValidationError::ValueTooHigh { .. }
317        ));
318    }
319
320    #[test]
321    fn test_select_options() {
322        let form = create_test_form();
323        let mut doc = Document::new("doc1", "ticket", "flow_ticket");
324        doc.set_value("title", json!("Valid"));
325        doc.set_value("status", json!("In Progress")); // Not in ["Open", "Closed"]
326
327        let res = validate_document(&doc, &form);
328        assert!(res.is_err());
329        assert!(matches!(
330            res.unwrap_err()[0],
331            DocumentValidationError::InvalidSelection { .. }
332        ));
333    }
334}