noml/
schema.rs

1//! # NOML Schema Validation
2//!
3//! Comprehensive schema validation system for NOML configurations. Provides
4//! type-safe validation with detailed error reporting to catch configuration
5//! errors early in development and deployment.
6//!
7//! ## Overview
8//!
9//! The schema system enables you to define expected structure and types for
10//! configuration files, ensuring data integrity and providing clear error
11//! messages when validation fails.
12//!
13//! ## Quick Start
14//!
15//! ```rust
16//! use noml::{Config, SchemaBuilder, FieldType};
17//!
18//! // Define schema using builder pattern
19//! let schema = SchemaBuilder::new()
20//!     .require_string("app_name")
21//!     .require_integer("port")
22//!     .optional_bool("debug")
23//!     .build();
24//!
25//! // Load and validate configuration
26//! let config = Config::from_string(r#"
27//!     app_name = "my-service"
28//!     port = 8080
29//!     debug = true
30//! "#)?;
31//!
32//! config.validate_schema(&schema)?;
33//!
34//! # Ok::<(), Box<dyn std::error::Error>>(())
35//! ```
36//!
37//! ## Advanced Schema Definition
38//!
39//! ```rust
40//! use noml::{Schema, FieldSchema, FieldType, Value};
41//! use std::collections::HashMap;
42//!
43//! // Create nested schema for database configuration
44//! let mut db_schema = Schema::new()
45//!     .required_field("host", FieldType::String)
46//!     .required_field("port", FieldType::Integer)
47//!     .optional_field("ssl", FieldType::Bool)
48//!     .field_with_default("timeout", FieldType::Integer, Value::integer(30));
49//!
50//! // Main application schema
51//! let app_schema = Schema::new()
52//!     .required_field("name", FieldType::String)
53//!     .required_field("database", FieldType::Table(db_schema))
54//!     .allow_additional(false);  // Strict validation
55//!
56//! # Ok::<(), Box<dyn std::error::Error>>(())
57//! ```
58//!
59//! ## Validation Features
60//!
61//! - **๐Ÿ” Type Checking** - Ensure values match expected types
62//! - **๐Ÿ“‹ Required Fields** - Validate presence of mandatory configuration
63//! - **๐Ÿ”ง Default Values** - Automatic insertion of missing optional fields
64//! - **๐Ÿ—๏ธ Nested Validation** - Deep validation of table structures
65//! - **๐Ÿ“ Descriptive Errors** - Clear messages with field paths
66//! - **๐Ÿ”“ Flexible Schemas** - Allow or reject additional fields
67
68use crate::error::{NomlError, Result};
69use crate::value::Value;
70use std::collections::HashMap;
71
72/// Schema definition for validating NOML configurations
73#[derive(Debug, Clone, PartialEq)]
74pub struct Schema {
75    /// Field definitions
76    pub fields: HashMap<String, FieldSchema>,
77    /// Whether to allow additional fields not defined in schema
78    pub allow_additional: bool,
79}
80
81/// Schema definition for a field
82#[derive(Debug, Clone, PartialEq)]
83pub struct FieldSchema {
84    /// Expected type of the field
85    pub field_type: FieldType,
86    /// Whether this field is required
87    pub required: bool,
88    /// Optional description for documentation
89    pub description: Option<String>,
90    /// Default value if field is missing
91    pub default: Option<Value>,
92}
93
94/// Supported field types for validation
95#[derive(Debug, Clone, PartialEq)]
96pub enum FieldType {
97    /// String value
98    String,
99    /// Integer value
100    Integer,
101    /// Float value
102    Float,
103    /// Boolean value
104    Bool,
105    /// Binary data
106    Binary,
107    /// DateTime value
108    DateTime,
109    /// Array of specific type
110    Array(Box<FieldType>),
111    /// Table/object with nested schema
112    Table(Schema),
113    /// Any type (no validation)
114    Any,
115    /// One of several types
116    Union(Vec<FieldType>),
117}
118
119impl Schema {
120    /// Create a new empty schema
121    pub fn new() -> Self {
122        Self {
123            fields: HashMap::new(),
124            allow_additional: true,
125        }
126    }
127
128    /// Add a required field to the schema
129    pub fn required_field(mut self, name: &str, field_type: FieldType) -> Self {
130        self.fields.insert(
131            name.to_string(),
132            FieldSchema {
133                field_type,
134                required: true,
135                description: None,
136                default: None,
137            },
138        );
139        self
140    }
141
142    /// Add an optional field to the schema
143    pub fn optional_field(mut self, name: &str, field_type: FieldType) -> Self {
144        self.fields.insert(
145            name.to_string(),
146            FieldSchema {
147                field_type,
148                required: false,
149                description: None,
150                default: None,
151            },
152        );
153        self
154    }
155
156    /// Add a field with a default value
157    pub fn field_with_default(mut self, name: &str, field_type: FieldType, default: Value) -> Self {
158        self.fields.insert(
159            name.to_string(),
160            FieldSchema {
161                field_type,
162                required: false,
163                description: None,
164                default: Some(default),
165            },
166        );
167        self
168    }
169
170    /// Set whether to allow additional fields
171    pub fn allow_additional(mut self, allow: bool) -> Self {
172        self.allow_additional = allow;
173        self
174    }
175
176    /// Validate a value against this schema
177    pub fn validate(&self, value: &Value) -> Result<()> {
178        match value {
179            Value::Table(table) => {
180                // Check required fields
181                for (field_name, field_schema) in &self.fields {
182                    if field_schema.required && !table.contains_key(field_name) {
183                        return Err(NomlError::validation(format!(
184                            "Required field '{field_name}' is missing"
185                        )));
186                    }
187                }
188
189                // Validate existing fields
190                for (key, val) in table {
191                    if let Some(field_schema) = self.fields.get(key) {
192                        self.validate_field_type(val, &field_schema.field_type, key)?;
193                    } else if !self.allow_additional {
194                        return Err(NomlError::validation(format!(
195                            "Additional field '{key}' is not allowed"
196                        )));
197                    }
198                }
199
200                Ok(())
201            }
202            _ => Err(NomlError::validation(
203                "Schema validation requires a table/object at the root".to_string(),
204            )),
205        }
206    }
207
208    /// Validate a field against its expected type
209    fn validate_field_type(
210        &self,
211        value: &Value,
212        expected_type: &FieldType,
213        field_path: &str,
214    ) -> Result<()> {
215        match (value, expected_type) {
216            (Value::String(_), FieldType::String) => Ok(()),
217            (Value::Integer(_), FieldType::Integer) => Ok(()),
218            (Value::Float(_), FieldType::Float) => Ok(()),
219            (Value::Bool(_), FieldType::Bool) => Ok(()),
220            (Value::Binary(_), FieldType::Binary) => Ok(()),
221            #[cfg(feature = "chrono")]
222            (Value::DateTime(_), FieldType::DateTime) => Ok(()),
223            (_, FieldType::Any) => Ok(()),
224
225            (Value::Array(arr), FieldType::Array(element_type)) => {
226                for (i, item) in arr.iter().enumerate() {
227                    let item_path = format!("{field_path}[{i}]");
228                    self.validate_field_type(item, element_type, &item_path)?;
229                }
230                Ok(())
231            }
232
233            (Value::Table(_), FieldType::Table(nested_schema)) => nested_schema.validate(value),
234
235            (val, FieldType::Union(types)) => {
236                for field_type in types {
237                    if self
238                        .validate_field_type(val, field_type, field_path)
239                        .is_ok()
240                    {
241                        return Ok(());
242                    }
243                }
244                Err(NomlError::validation(format!(
245                    "Field '{field_path}' does not match any of the expected types"
246                )))
247            }
248
249            _ => Err(NomlError::validation(format!(
250                "Field '{field_path}' has incorrect type. Expected {expected_type:?}, got {:?}",
251                self.value_type_name(value)
252            ))),
253        }
254    }
255
256    /// Get a human-readable type name for a value
257    fn value_type_name(&self, value: &Value) -> &'static str {
258        match value {
259            Value::String(_) => "String",
260            Value::Integer(_) => "Integer",
261            Value::Float(_) => "Float",
262            Value::Bool(_) => "Bool",
263            Value::Array(_) => "Array",
264            Value::Table(_) => "Table",
265            Value::Null => "Null",
266            Value::Size(_) => "Size",
267            Value::Duration(_) => "Duration",
268            Value::Binary(_) => "Binary",
269            #[cfg(feature = "chrono")]
270            Value::DateTime(_) => "DateTime",
271        }
272    }
273}
274
275impl Default for Schema {
276    fn default() -> Self {
277        Self::new()
278    }
279}
280
281/// Builder for creating schemas more easily
282pub struct SchemaBuilder {
283    schema: Schema,
284}
285
286impl SchemaBuilder {
287    /// Create a new schema builder
288    pub fn new() -> Self {
289        Self {
290            schema: Schema::new(),
291        }
292    }
293
294    /// Add a required string field
295    pub fn require_string(mut self, name: &str) -> Self {
296        self.schema = self.schema.required_field(name, FieldType::String);
297        self
298    }
299
300    /// Add a required integer field
301    pub fn require_integer(mut self, name: &str) -> Self {
302        self.schema = self.schema.required_field(name, FieldType::Integer);
303        self
304    }
305
306    /// Add an optional boolean field
307    pub fn optional_bool(mut self, name: &str) -> Self {
308        self.schema = self.schema.optional_field(name, FieldType::Bool);
309        self
310    }
311
312    /// Build the final schema
313    pub fn build(self) -> Schema {
314        self.schema
315    }
316}
317
318impl Default for SchemaBuilder {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::value::Value;
328    use std::collections::BTreeMap;
329
330    #[test]
331    fn test_basic_schema_validation() {
332        let schema = Schema::new()
333            .required_field("name", FieldType::String)
334            .required_field("port", FieldType::Integer)
335            .optional_field("debug", FieldType::Bool);
336
337        // Valid config
338        let mut config = BTreeMap::new();
339        config.insert("name".to_string(), Value::String("test".to_string()));
340        config.insert("port".to_string(), Value::Integer(8080));
341        config.insert("debug".to_string(), Value::Bool(true));
342
343        let valid_value = Value::Table(config);
344        assert!(schema.validate(&valid_value).is_ok());
345
346        // Missing required field
347        let mut invalid_config = BTreeMap::new();
348        invalid_config.insert("name".to_string(), Value::String("test".to_string()));
349        // missing port
350
351        let invalid_value = Value::Table(invalid_config);
352        assert!(schema.validate(&invalid_value).is_err());
353    }
354
355    #[test]
356    fn test_schema_builder() {
357        let schema = SchemaBuilder::new()
358            .require_string("app_name")
359            .require_integer("version")
360            .optional_bool("debug")
361            .build();
362
363        let mut config = BTreeMap::new();
364        config.insert("app_name".to_string(), Value::String("MyApp".to_string()));
365        config.insert("version".to_string(), Value::Integer(1));
366
367        let value = Value::Table(config);
368        assert!(schema.validate(&value).is_ok());
369    }
370
371    #[test]
372    fn test_array_validation() {
373        let schema =
374            Schema::new().required_field("tags", FieldType::Array(Box::new(FieldType::String)));
375
376        let mut config = BTreeMap::new();
377        config.insert(
378            "tags".to_string(),
379            Value::Array(vec![
380                Value::String("web".to_string()),
381                Value::String("api".to_string()),
382            ]),
383        );
384
385        let value = Value::Table(config);
386        assert!(schema.validate(&value).is_ok());
387
388        // Invalid array element type
389        let mut invalid_config = BTreeMap::new();
390        invalid_config.insert(
391            "tags".to_string(),
392            Value::Array(vec![
393                Value::String("web".to_string()),
394                Value::Integer(123), // Wrong type
395            ]),
396        );
397
398        let invalid_value = Value::Table(invalid_config);
399        assert!(schema.validate(&invalid_value).is_err());
400    }
401}