Skip to main content

yaml_schema/validation/
strings.rs

1use log::debug;
2use regex::Regex;
3
4use crate::Context;
5use crate::Result;
6use crate::Validator;
7use crate::schemas::StringFormat;
8use crate::schemas::StringSchema;
9use crate::utils::humanize_yaml_data;
10use crate::validation::formats;
11
12impl Validator for StringSchema {
13    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
14        let errors = self.do_validate(value);
15        if !errors.is_empty() {
16            for error in errors {
17                context.add_error(value, error);
18            }
19        }
20        Ok(())
21    }
22}
23
24impl StringSchema {
25    fn do_validate(&self, value: &saphyr::MarkedYaml) -> Vec<String> {
26        debug!("do_validate: {:?}", value.data);
27        let mut errors = Vec::new();
28
29        if let saphyr::YamlData::Value(scalar) = &value.data
30            && let saphyr::Scalar::String(s) = scalar
31        {
32            // TODO: add enum validation
33            let enum_strings = None;
34            debug!("enum_strings: {enum_strings:?}");
35            validate_string(
36                &mut errors,
37                self.min_length,
38                self.max_length,
39                self.pattern.as_ref(),
40                self.format.as_ref(),
41                enum_strings.as_ref(),
42                s,
43            );
44        } else {
45            errors.push(format!(
46                "Expected a string, but got: {}",
47                humanize_yaml_data(&value.data)
48            ));
49        }
50        errors
51    }
52}
53
54/// Just trying to isolate the actual validation into a function that doesn't take a context
55pub fn validate_string(
56    errors: &mut Vec<String>,
57    min_length: Option<usize>,
58    max_length: Option<usize>,
59    pattern: Option<&Regex>,
60    format: Option<&StringFormat>,
61    r#enum: Option<&Vec<String>>,
62    str_value: &str,
63) {
64    // JSON Schema string length is the number of Unicode scalar values (JSON / RFC 8259
65    // "characters"), not UTF-8 byte length.
66    let char_len =
67        (min_length.is_some() || max_length.is_some()).then(|| str_value.chars().count());
68    if let Some(n) = char_len
69        && let Some(min_length) = min_length
70        && n < min_length
71    {
72        errors.push(format!("String is too short! (min length: {min_length})"));
73    }
74    if let Some(n) = char_len
75        && let Some(max_length) = max_length
76        && n > max_length
77    {
78        errors.push(format!("String is too long! (max length: {max_length})"));
79    }
80    if let Some(regex) = pattern
81        && !regex.is_match(str_value)
82    {
83        errors.push(format!(
84            "String does not match regular expression {}!",
85            regex.as_str()
86        ));
87    }
88    if let Some(fmt) = format
89        && let Some(err) = formats::validate_format(fmt, str_value)
90    {
91        errors.push(err);
92    }
93    if let Some(enum_values) = r#enum
94        && !enum_values.contains(&str_value.to_string())
95    {
96        errors.push(format!("String is not in enum: {enum_values:?}"));
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use crate::Engine;
103    use crate::RootSchema;
104    use crate::YamlSchema;
105    use saphyr::LoadableYamlNode;
106
107    use super::*;
108
109    #[test]
110    fn test_engine_validate_string() {
111        let schema = StringSchema::default();
112        let root_schema = RootSchema::new(YamlSchema::typed_string(schema));
113        let context = Engine::evaluate(&root_schema, "some string", false).unwrap();
114        assert!(!context.has_errors());
115    }
116
117    #[test]
118    fn test_engine_validate_string_with_min_length() {
119        let schema = StringSchema {
120            min_length: Some(5),
121            ..Default::default()
122        };
123        let root_schema = RootSchema::new(YamlSchema::typed_string(schema));
124        let context = Engine::evaluate(&root_schema, "hello", false).unwrap();
125        assert!(!context.has_errors());
126        let context = Engine::evaluate(&root_schema, "hell", false).unwrap();
127        assert!(context.has_errors());
128    }
129
130    #[test]
131    fn test_validate_string() {
132        let mut errors = Vec::new();
133        validate_string(&mut errors, None, None, None, None, None, "hello");
134        assert!(errors.is_empty());
135    }
136
137    #[test]
138    fn test_validate_string_with_min_length() {
139        let mut errors = Vec::new();
140        validate_string(&mut errors, Some(5), None, None, None, None, "hello");
141        assert!(errors.is_empty());
142        validate_string(&mut errors, Some(5), None, None, None, None, "hell");
143        assert!(!errors.is_empty());
144        assert_eq!(
145            errors.first().unwrap(),
146            "String is too short! (min length: 5)"
147        );
148    }
149
150    /// `minLength` / `maxLength` count Unicode scalars, not UTF-8 bytes (JSON Schema).
151    #[test]
152    fn test_validate_string_length_counts_unicode_scalars_not_utf8_bytes() {
153        // Three Greek letters: 3 characters, 6 UTF-8 bytes.
154        let greek = "αβγ";
155        assert_eq!(greek.len(), 6);
156        assert_eq!(greek.chars().count(), 3);
157
158        let mut errors = Vec::new();
159        validate_string(&mut errors, None, Some(3), None, None, None, greek);
160        assert!(
161            errors.is_empty(),
162            "maxLength 3 must allow three characters (not three bytes)"
163        );
164
165        let mut errors = Vec::new();
166        validate_string(&mut errors, None, Some(2), None, None, None, greek);
167        assert_eq!(errors.len(), 1);
168
169        let mut errors = Vec::new();
170        validate_string(&mut errors, Some(4), None, None, None, None, greek);
171        assert_eq!(
172            errors.first().map(|s| s.as_str()),
173            Some("String is too short! (min length: 4)")
174        );
175    }
176
177    #[test]
178    fn test_string_schema_validation() {
179        let schema = StringSchema::default();
180        let docs = saphyr::MarkedYaml::load_from_str("Washington").unwrap();
181        let value = docs.first().unwrap();
182        let context = Context::default();
183        let result = schema.validate(&context, value);
184        assert!(result.is_ok());
185    }
186
187    #[test]
188    fn test_string_schema_doesnt_validate_object() {
189        let yaml = "an: [arbitrarily, nested, data, structure]";
190        let doc = saphyr::MarkedYaml::load_from_str(yaml).unwrap();
191        let marked_yaml = doc.first().unwrap();
192        let string_schema: StringSchema = StringSchema::default();
193        let context = Context::default();
194        let result = string_schema.validate(&context, marked_yaml);
195        assert!(result.is_ok());
196        assert!(context.has_errors());
197    }
198
199    #[test]
200    fn test_validate_string_with_format() {
201        let mut errors = Vec::new();
202        let fmt = StringFormat::Email;
203        validate_string(
204            &mut errors,
205            None,
206            None,
207            None,
208            Some(&fmt),
209            None,
210            "user@example.com",
211        );
212        assert!(errors.is_empty());
213
214        validate_string(
215            &mut errors,
216            None,
217            None,
218            None,
219            Some(&fmt),
220            None,
221            "not-an-email",
222        );
223        assert_eq!(errors.len(), 1);
224        assert!(errors[0].contains("email"));
225    }
226
227    #[test]
228    fn test_engine_validate_string_with_format() {
229        let schema = StringSchema {
230            format: Some(StringFormat::Date),
231            ..Default::default()
232        };
233        let root_schema = RootSchema::new(YamlSchema::typed_string(schema));
234        let context = Engine::evaluate(&root_schema, "2024-01-15", false).unwrap();
235        assert!(!context.has_errors());
236
237        let context = Engine::evaluate(&root_schema, "not-a-date", false).unwrap();
238        assert!(context.has_errors());
239    }
240
241    #[test]
242    fn test_validate_string_unknown_format_always_passes() {
243        let mut errors = Vec::new();
244        let fmt = StringFormat::Unknown("custom".to_string());
245        validate_string(&mut errors, None, None, None, Some(&fmt), None, "anything");
246        assert!(errors.is_empty());
247    }
248}