Skip to main content

yaml_edit/
schema.rs

1//! YAML Schema validation support
2//!
3//! This module provides schema validation for YAML documents, supporting
4//! the standard YAML schemas: Failsafe, JSON, and Core.
5
6use crate::scalar::{ScalarType, ScalarValue};
7use crate::yaml::{Document, TaggedNode};
8use rowan::ast::AstNode;
9
10/// Specific type of validation error that occurred
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ValidationErrorKind {
13    /// A scalar type is not allowed in the current schema
14    TypeNotAllowed {
15        /// The scalar type that was found
16        found_type: ScalarType,
17        /// The types that are allowed in this schema
18        allowed_types: Vec<ScalarType>,
19    },
20    /// Custom validation constraint failed
21    CustomConstraintFailed {
22        /// The constraint that failed
23        constraint_name: String,
24        /// The actual value that failed validation
25        actual_value: String,
26    },
27    /// Type coercion failed
28    CoercionFailed {
29        /// The type that was found
30        from_type: ScalarType,
31        /// The types that coercion was attempted to
32        to_types: Vec<ScalarType>,
33    },
34}
35
36/// Error that occurs during schema validation
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ValidationError {
39    /// The specific kind of validation error
40    pub kind: ValidationErrorKind,
41    /// Path to the node that failed validation (e.g., "root.items\[0\].name")
42    pub path: String,
43    /// Name of the schema that was being validated against
44    pub schema_name: String,
45}
46
47impl ValidationError {
48    /// Create a new type not allowed error
49    pub fn type_not_allowed(
50        path: impl Into<String>,
51        schema_name: impl Into<String>,
52        found_type: ScalarType,
53        allowed_types: Vec<ScalarType>,
54    ) -> Self {
55        Self {
56            kind: ValidationErrorKind::TypeNotAllowed {
57                found_type,
58                allowed_types,
59            },
60            path: path.into(),
61            schema_name: schema_name.into(),
62        }
63    }
64
65    /// Create a new custom constraint failed error
66    pub fn custom_constraint_failed(
67        path: impl Into<String>,
68        schema_name: impl Into<String>,
69        constraint_name: impl Into<String>,
70        actual_value: impl Into<String>,
71    ) -> Self {
72        Self {
73            kind: ValidationErrorKind::CustomConstraintFailed {
74                constraint_name: constraint_name.into(),
75                actual_value: actual_value.into(),
76            },
77            path: path.into(),
78            schema_name: schema_name.into(),
79        }
80    }
81
82    /// Create a new coercion failed error
83    pub fn coercion_failed(
84        path: impl Into<String>,
85        schema_name: impl Into<String>,
86        from_type: ScalarType,
87        to_types: Vec<ScalarType>,
88    ) -> Self {
89        Self {
90            kind: ValidationErrorKind::CoercionFailed {
91                from_type,
92                to_types,
93            },
94            path: path.into(),
95            schema_name: schema_name.into(),
96        }
97    }
98
99    /// Get a human-readable error message
100    pub fn message(&self) -> String {
101        match &self.kind {
102            ValidationErrorKind::TypeNotAllowed {
103                found_type,
104                allowed_types,
105            } => {
106                format!(
107                    "type {:?} not allowed in {} schema, expected one of {:?}",
108                    found_type, self.schema_name, allowed_types
109                )
110            }
111            ValidationErrorKind::CustomConstraintFailed {
112                constraint_name,
113                actual_value,
114            } => {
115                format!("custom constraint '{constraint_name}' failed for value '{actual_value}'")
116            }
117            ValidationErrorKind::CoercionFailed {
118                from_type,
119                to_types,
120            } => {
121                format!(
122                    "cannot coerce {:?} to any of {:?} in {} schema",
123                    from_type, to_types, self.schema_name
124                )
125            }
126        }
127    }
128}
129
130impl std::fmt::Display for ValidationError {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "Validation error at {}: {}", self.path, self.message())
133    }
134}
135
136impl std::error::Error for ValidationError {}
137
138/// Result type for schema validation operations
139pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
140
141/// Result of a custom validation function
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum CustomValidationResult {
144    /// Validation passed
145    Valid,
146    /// Validation failed with a specific constraint name and reason
147    Invalid {
148        /// Name of the constraint that failed (e.g., "email_format", "port_range")
149        constraint: String,
150        /// Human-readable reason for failure
151        reason: String,
152    },
153}
154
155impl CustomValidationResult {
156    /// Create a validation failure
157    pub fn invalid(constraint: impl Into<String>, reason: impl Into<String>) -> Self {
158        Self::Invalid {
159            constraint: constraint.into(),
160            reason: reason.into(),
161        }
162    }
163
164    /// Check if validation passed
165    pub fn is_valid(&self) -> bool {
166        matches!(self, Self::Valid)
167    }
168
169    /// Check if validation failed
170    pub fn is_invalid(&self) -> bool {
171        matches!(self, Self::Invalid { .. })
172    }
173}
174
175/// Custom validation function for scalar values
176///
177/// Takes (value, path) and returns CustomValidationResult
178pub type CustomValidator = Box<dyn Fn(&str, &str) -> CustomValidationResult + Send + Sync>;
179
180/// Custom schema definition with user-defined validation rules
181pub struct CustomSchema {
182    /// Schema name for error messages
183    pub name: String,
184    /// Allowed scalar types in this schema
185    pub allowed_types: Vec<ScalarType>,
186    /// Custom validation functions by type
187    pub custom_validators: std::collections::HashMap<ScalarType, CustomValidator>,
188    /// Whether to allow type coercion
189    pub allow_coercion: bool,
190}
191
192impl std::fmt::Debug for CustomSchema {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("CustomSchema")
195            .field("name", &self.name)
196            .field("allowed_types", &self.allowed_types)
197            .field("allow_coercion", &self.allow_coercion)
198            .field(
199                "validators",
200                &format!("<{} validators>", self.custom_validators.len()),
201            )
202            .finish()
203    }
204}
205
206impl CustomSchema {
207    /// Create a new custom schema
208    pub fn new(name: impl Into<String>) -> Self {
209        Self {
210            name: name.into(),
211            allowed_types: Vec::new(),
212            custom_validators: std::collections::HashMap::new(),
213            allow_coercion: true,
214        }
215    }
216
217    /// Allow a specific scalar type
218    pub fn allow_type(mut self, scalar_type: ScalarType) -> Self {
219        if !self.allowed_types.contains(&scalar_type) {
220            self.allowed_types.push(scalar_type);
221        }
222        self
223    }
224
225    /// Allow multiple scalar types
226    pub fn allow_types(mut self, types: &[ScalarType]) -> Self {
227        for &scalar_type in types {
228            if !self.allowed_types.contains(&scalar_type) {
229                self.allowed_types.push(scalar_type);
230            }
231        }
232        self
233    }
234
235    /// Add a custom validator for a specific type
236    pub fn with_validator<F>(mut self, scalar_type: ScalarType, validator: F) -> Self
237    where
238        F: Fn(&str, &str) -> CustomValidationResult + Send + Sync + 'static,
239    {
240        self.custom_validators
241            .insert(scalar_type, Box::new(validator));
242        self
243    }
244
245    /// Disable type coercion for strict validation
246    pub fn strict(mut self) -> Self {
247        self.allow_coercion = false;
248        self
249    }
250
251    /// Check if a scalar type is allowed
252    pub fn allows_type(&self, scalar_type: ScalarType) -> bool {
253        self.allowed_types.contains(&scalar_type)
254    }
255
256    /// Validate a scalar value with custom rules
257    pub fn validate_scalar(&self, content: &str, path: &str) -> Result<(), ValidationError> {
258        self.validate_scalar_value(&ScalarValue::parse(content.trim()), path)
259    }
260
261    fn validate_scalar_value(
262        &self,
263        scalar_value: &ScalarValue,
264        path: &str,
265    ) -> Result<(), ValidationError> {
266        let content = scalar_value.value();
267        let scalar_type = scalar_value.scalar_type();
268
269        check_type(
270            scalar_value,
271            &self.allowed_types,
272            self.allow_coercion,
273            path,
274            &self.name,
275        )?;
276
277        if let Some(validator) = self.custom_validators.get(&scalar_type) {
278            let result = validator(content, path);
279            if let CustomValidationResult::Invalid { constraint, reason } = result {
280                return Err(ValidationError::custom_constraint_failed(
281                    path,
282                    &self.name,
283                    format!("{constraint}: {reason}"),
284                    content,
285                ));
286            }
287        }
288
289        Ok(())
290    }
291}
292
293/// Check that `value` has one of the `allowed` types or, if `coerce` is
294/// set, can be coerced to one of them.
295fn check_type(
296    value: &ScalarValue,
297    allowed: &[ScalarType],
298    coerce: bool,
299    path: &str,
300    schema_name: &str,
301) -> Result<(), ValidationError> {
302    let scalar_type = value.scalar_type();
303    if allowed.contains(&scalar_type) {
304        return Ok(());
305    }
306    if !coerce {
307        return Err(ValidationError::type_not_allowed(
308            path,
309            schema_name,
310            scalar_type,
311            allowed.to_vec(),
312        ));
313    }
314    if allowed.iter().any(|&t| value.coerce_to_type(t).is_some()) {
315        Ok(())
316    } else {
317        Err(ValidationError::coercion_failed(
318            path,
319            schema_name,
320            scalar_type,
321            allowed.to_vec(),
322        ))
323    }
324}
325
326/// YAML Schema types as defined in YAML 1.2 specification
327#[derive(Debug)]
328pub enum Schema {
329    /// Failsafe schema - only strings, sequences, and mappings
330    Failsafe,
331    /// JSON schema - JSON-compatible types only
332    Json,
333    /// Core schema - full YAML 1.2 type system
334    Core,
335    /// User-defined custom schema
336    Custom(CustomSchema),
337}
338
339impl PartialEq for Schema {
340    fn eq(&self, other: &Self) -> bool {
341        match (self, other) {
342            (Schema::Failsafe, Schema::Failsafe)
343            | (Schema::Json, Schema::Json)
344            | (Schema::Core, Schema::Core) => true,
345            (Schema::Custom(a), Schema::Custom(b)) => a.name == b.name,
346            _ => false,
347        }
348    }
349}
350
351impl Schema {
352    /// Get the name of this schema
353    pub fn name(&self) -> &str {
354        match self {
355            Schema::Failsafe => "failsafe",
356            Schema::Json => "json",
357            Schema::Core => "core",
358            Schema::Custom(custom) => &custom.name,
359        }
360    }
361
362    /// Check if a scalar type is allowed in this schema
363    pub fn allows_scalar_type(&self, scalar_type: ScalarType) -> bool {
364        self.allowed_scalar_types().contains(&scalar_type)
365    }
366
367    /// Get the allowed scalar types for this schema
368    pub fn allowed_scalar_types(&self) -> Vec<ScalarType> {
369        match self {
370            Schema::Failsafe => vec![ScalarType::String],
371            Schema::Json => vec![
372                ScalarType::String,
373                ScalarType::Integer,
374                ScalarType::Float,
375                ScalarType::Boolean,
376                ScalarType::Null,
377            ],
378            Schema::Core => vec![
379                ScalarType::String,
380                ScalarType::Integer,
381                ScalarType::Float,
382                ScalarType::Boolean,
383                ScalarType::Null,
384                #[cfg(feature = "base64")]
385                ScalarType::Binary,
386                ScalarType::Timestamp,
387                ScalarType::Regex,
388            ],
389            Schema::Custom(custom) => custom.allowed_types.clone(),
390        }
391    }
392}
393
394/// Schema validator for YAML documents
395#[derive(Debug)]
396pub struct SchemaValidator {
397    schema: Schema,
398    strict: bool,
399}
400
401impl SchemaValidator {
402    /// Create a new schema validator
403    pub fn new(schema: Schema) -> Self {
404        Self {
405            schema,
406            strict: false,
407        }
408    }
409
410    /// Create a failsafe schema validator
411    pub fn failsafe() -> Self {
412        Self::new(Schema::Failsafe)
413    }
414
415    /// Create a JSON schema validator  
416    pub fn json() -> Self {
417        Self::new(Schema::Json)
418    }
419
420    /// Create a core schema validator
421    pub fn core() -> Self {
422        Self::new(Schema::Core)
423    }
424
425    /// Create a validator for a custom schema
426    pub fn custom(schema: CustomSchema) -> Self {
427        Self::new(Schema::Custom(schema))
428    }
429
430    /// Enable strict mode - disallow type coercion
431    pub fn strict(mut self) -> Self {
432        self.strict = true;
433        self
434    }
435
436    /// Get the schema type
437    pub fn schema(&self) -> &Schema {
438        &self.schema
439    }
440
441    /// Validate a YAML document against the schema
442    pub fn validate(&self, document: &Document) -> ValidationResult<()> {
443        self.check_document(document, false)
444    }
445
446    /// Check if a document can be coerced to match the schema
447    ///
448    /// This does not run the validators of a custom schema.
449    pub fn can_coerce(&self, document: &Document) -> ValidationResult<()> {
450        self.check_document(document, !self.strict)
451    }
452
453    fn check_document(&self, document: &Document, coerce_only: bool) -> ValidationResult<()> {
454        let mut errors = Vec::new();
455        if let Some(root) = document.root_node() {
456            self.check_node(&root, "root", coerce_only, &mut errors);
457        }
458        if errors.is_empty() {
459            Ok(())
460        } else {
461            Err(errors)
462        }
463    }
464
465    fn check_node(
466        &self,
467        node: &rowan::SyntaxNode<crate::yaml::Lang>,
468        path: &str,
469        coerce_only: bool,
470        errors: &mut Vec<ValidationError>,
471    ) {
472        use crate::yaml::{extract, Mapping, Scalar, Sequence};
473
474        if let Some(scalar) = extract::<Scalar>(node) {
475            let value = ScalarValue::from_scalar(&scalar);
476            self.check_scalar(&value, false, path, coerce_only, errors);
477        } else if let Some(tagged_node) = extract::<TaggedNode>(node) {
478            if let Some(content) = tagged_node
479                .as_sequence()
480                .map(|s| s.syntax().clone())
481                .or_else(|| tagged_node.as_mapping().map(|m| m.syntax().clone()))
482            {
483                self.check_node(&content, path, coerce_only, errors);
484            } else if let Some(value) = tagged_scalar_value(&tagged_node) {
485                self.check_scalar(&value, true, path, coerce_only, errors);
486            }
487        } else if let Some(sequence) = extract::<Sequence>(node) {
488            for (i, item) in sequence.items().enumerate() {
489                self.check_node(&item, &format!("{path}[{i}]"), coerce_only, errors);
490            }
491        } else if let Some(mapping) = extract::<Mapping>(node) {
492            // The schema applies to the values, not the keys
493            for (key_node, value_node) in mapping.pairs() {
494                let key_name = key_node.text().to_string().trim().to_string();
495                self.check_node(
496                    &value_node,
497                    &format!("{path}.{key_name}"),
498                    coerce_only,
499                    errors,
500                );
501            }
502        }
503    }
504
505    fn check_scalar(
506        &self,
507        value: &ScalarValue,
508        tagged: bool,
509        path: &str,
510        coerce_only: bool,
511        errors: &mut Vec<ValidationError>,
512    ) {
513        let result = match &self.schema {
514            Schema::Custom(custom_schema) if !coerce_only => {
515                custom_schema.validate_scalar_value(value, path)
516            }
517            // A tagged scalar has its type written out, so it is not coerced
518            _ => check_type(
519                value,
520                &self.schema.allowed_scalar_types(),
521                !tagged && !self.strict,
522                path,
523                self.schema.name(),
524            ),
525        };
526        errors.extend(result.err());
527    }
528}
529
530/// The value of a tagged scalar, typed by its tag.
531fn tagged_scalar_value(tagged_node: &TaggedNode) -> Option<ScalarValue> {
532    let value = ScalarValue::from_tagged(tagged_node)?;
533    let scalar_type = match tagged_node.normalized_tag().as_deref() {
534        Some(crate::tag::TIMESTAMP) => ScalarType::Timestamp,
535        Some(crate::tag::REGEX) => ScalarType::Regex,
536        #[cfg(feature = "base64")]
537        Some(crate::tag::BINARY) => ScalarType::Binary,
538        _ => return Some(value),
539    };
540    Some(value.with_type(scalar_type))
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_validator_creation() {
549        let failsafe = SchemaValidator::failsafe();
550        assert_eq!(*failsafe.schema(), Schema::Failsafe);
551        assert!(!failsafe.strict);
552
553        let json = SchemaValidator::json();
554        assert_eq!(*json.schema(), Schema::Json);
555
556        let core = SchemaValidator::core();
557        assert_eq!(*core.schema(), Schema::Core);
558
559        let strict_validator = SchemaValidator::json().strict();
560        assert!(strict_validator.strict);
561    }
562}