Skip to main content

aam_core/aaml/
validation.rs

1//! Schema validation methods for [`AAML`](AAML).
2
3use super::AAML;
4use crate::aaml::parsing;
5use crate::error::AamlError;
6use crate::types::resolve_builtin;
7use std::collections::HashMap;
8
9impl AAML {
10    /// Validates a single field value against any schema that declares it.
11    ///
12    /// If the field is not declared in any schema the function succeeds silently.
13    pub(super) fn validate_against_schemas(
14        &self,
15        field: &str,
16        value: &str,
17    ) -> Result<(), AamlError> {
18        for (schema_name, schema_def) in &self.schemas {
19            if let Some(type_name) = schema_def.fields.get(field) {
20                return self.validate_typed_field(type_name, value, schema_name, field);
21            }
22        }
23        Ok(())
24    }
25
26    /// Validates `value` against `type_name`, checking:
27    /// 1. Registered custom types.
28    /// 2. Nested schema types (`type_name` matches a registered schema name).
29    /// 3. `list<T>` — validates every element of a `[...]` literal against `T`.
30    /// 4. Built-in module types (`math::`, `time::`, `physics::`, primitives).
31    ///
32    /// Returns a [`AamlError::SchemaValidationError`] on failure.
33    pub(crate) fn validate_typed_field(
34        &self,
35        type_name: &str,
36        value: &str,
37        schema_name: &str,
38        field: &str,
39    ) -> Result<(), AamlError> {
40        let make_err = |details: String| AamlError::SchemaValidationError {
41            schema: schema_name.to_string(),
42            field: field.to_string(),
43            type_name: type_name.to_string(),
44            details,
45            diagnostics: None,
46        };
47
48        // 1. Registered custom type alias
49        if let Some(type_def) = self.types.get(type_name) {
50            return type_def
51                .validate(value, self)
52                .map_err(|e| make_err(e.to_string()));
53        }
54
55        // 2. Nested schema — type_name matches a registered schema name
56        if let Some(nested_schema) = self.schemas.get(type_name) {
57            let fields = nested_schema.fields.clone();
58            return self
59                .validate_inline_object_against_schema(value, type_name, &fields)
60                .map_err(|e| make_err(e.to_string()));
61        }
62
63        // 3. Built-in types
64        resolve_builtin(type_name).map_or_else(
65            |_| Err(make_err(format!("Unknown type '{type_name}'"))),
66            |type_def| {
67                type_def
68                    .validate(value, self)
69                    .map_err(|e| make_err(e.to_string()))
70            },
71        )
72    }
73
74    /// Validates an inline object literal `{ key = val, ... }` against the
75    /// fields of the named nested schema.
76    ///
77    /// - Required fields (not marked `*`) declared in the schema must be present.
78    /// - Optional fields (marked `*`) may be absent; if present they are validated.
79    /// - Each value is validated against its declared type (recursively).
80    fn validate_inline_object_against_schema(
81        &self,
82        value: &str,
83        schema_name: &str,
84        schema_fields: &HashMap<String, String>,
85    ) -> Result<(), AamlError> {
86        if !parsing::is_inline_object(value) {
87            return Err(AamlError::InvalidValue {
88                details: format!(
89                    "Field typed as schema '{schema_name}' must be an inline object '{{ k = v, ... }}'"
90                ),
91                expected: "inline object format: { key = value, ... }".to_string(),
92                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
93                    "Schema field must be an object",
94                    format!("Expected inline object for schema '{schema_name}', got: '{value}'"),
95                    "Use format: { key1 = value1, key2 = value2 }",
96                ))),
97            });
98        }
99
100        let pairs = parsing::parse_inline_object(value)?;
101
102        let pair_map: HashMap<&str, &str> = pairs
103            .iter()
104            .map(|(k, v)| (k.as_str(), v.as_str()))
105            .collect();
106
107        // Fetch optional set from the registered schema (if still available).
108        let optional_fields = self
109            .schemas
110            .get(schema_name)
111            .map(|s| s.optional_fields.clone())
112            .unwrap_or_default();
113
114        for (field, type_name) in schema_fields {
115            match pair_map.get(field.as_str()) {
116                None => {
117                    // Missing field — only an error for required fields
118                    if !optional_fields.contains(field.as_str()) {
119                        return Err(AamlError::SchemaValidationError {
120                            schema: schema_name.to_string(),
121                            field: field.clone(),
122                            type_name: type_name.clone(),
123                            details: format!(
124                                "Missing field '{field}' in inline object for schema '{schema_name}'"
125                            ),
126                            diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
127                                "Missing required field",
128                                format!(
129                                    "Field '{field}' is required in schema '{schema_name}' but not provided"
130                                ),
131                                format!("Add field: {field} = <value>"),
132                            ))),
133                        });
134                    }
135                }
136                Some(field_value) => {
137                    self.validate_typed_field(type_name, field_value, schema_name, field)?;
138                }
139            }
140        }
141
142        Ok(())
143    }
144
145    /// Checks every **required** field in every registered schema against the current map.
146    /// Optional fields (declared with `*`) are skipped.
147    pub fn validate_schemas_completeness(&self) -> Result<(), AamlError> {
148        let names: Vec<&str> = self
149            .schemas
150            .keys()
151            .map(std::string::String::as_str)
152            .collect();
153        self.validate_schemas_completeness_for(&names)
154    }
155
156    // Medium Complexity
157    /// Checks required fields only for the named schemas.
158    /// Used by `@derive` to validate only child-defined schemas, not inherited ones.
159    pub fn validate_schemas_completeness_for(
160        &self,
161        schema_names: &[&str],
162    ) -> Result<(), AamlError> {
163        for name in schema_names {
164            let Some(schema_def) = self.schemas.get(*name) else {
165                continue;
166            };
167            for (field, type_name) in &schema_def.fields {
168                if schema_def.is_optional(field) {
169                    continue;
170                }
171                if !self.map.contains_key(field.as_str()) {
172                    return Err(AamlError::SchemaValidationError {
173                        schema: name.to_string(),
174                        field: field.clone(),
175                        type_name: type_name.clone(),
176                        details: format!("Missing required field '{field}'"),
177                        diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
178                            "Missing required field in schema",
179                            format!(
180                                "Schema '{name}' requires field '{field}' of type '{type_name}'"
181                            ),
182                            format!("Define the field in your configuration: {field} = <value>"),
183                        ))),
184                    });
185                }
186            }
187        }
188        Ok(())
189    }
190
191    /// Validates a complete `data` map against the named schema.
192    ///
193    /// For every **required** field declared in the schema the method checks:
194    /// 1. The key is present in `data`.
195    /// 2. The value satisfies the declared type (including nested schemas and lists).
196    ///
197    /// Optional fields (declared with `*`) are only validated when they are
198    /// present in `data`; their absence is not an error.
199    pub fn apply_schema(
200        &self,
201        schema_name: &str,
202        data: &HashMap<String, String>,
203    ) -> Result<(), AamlError> {
204        let schema = self
205            .schemas
206            .get(schema_name)
207            .ok_or_else(|| AamlError::NotFound {
208                key: schema_name.to_string(),
209                context: "schema registry".to_string(),
210                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
211                    "Schema not found",
212                    format!("Schema '{schema_name}' does not exist"),
213                    "Check your @schema definitions",
214                ))),
215            })?;
216
217        for (field, type_name) in &schema.fields {
218            match data.get(field) {
219                None => {
220                    if !schema.optional_fields.contains(field.as_str()) {
221                        return Err(AamlError::SchemaValidationError {
222                            schema: schema_name.to_string(),
223                            field: field.clone(),
224                            type_name: type_name.clone(),
225                            details: format!("Missing required field '{field}'"),
226                            diagnostics: None,
227                        });
228                    }
229                }
230                Some(value) => {
231                    self.validate_typed_field(type_name, value, schema_name, field)?;
232                }
233            }
234        }
235
236        Ok(())
237    }
238}