yaml_schema/schemas/
integer.rs

1use crate::ConstValue;
2use crate::Number;
3use crate::Result;
4use crate::schemas::BaseSchema;
5use crate::schemas::SchemaMetadata;
6use crate::utils::format_marker;
7use crate::validation::Context;
8use crate::validation::Validator;
9use saphyr::AnnotatedMapping;
10use saphyr::MarkedYaml;
11use saphyr::Scalar;
12use saphyr::YamlData;
13use std::cmp::Ordering;
14
15/// An integer schema
16#[derive(Debug, Default, PartialEq)]
17pub struct IntegerSchema {
18    pub base: BaseSchema,
19    pub minimum: Option<Number>,
20    pub maximum: Option<Number>,
21    pub exclusive_minimum: Option<Number>,
22    pub exclusive_maximum: Option<Number>,
23    pub multiple_of: Option<Number>,
24}
25
26impl SchemaMetadata for IntegerSchema {
27    fn get_accepted_keys() -> &'static [&'static str] {
28        &[
29            "minimum",
30            "maximum",
31            "exclusiveMinimum",
32            "exclusiveMaximum",
33            "multipleOf",
34        ]
35    }
36}
37
38impl TryFrom<&MarkedYaml<'_>> for IntegerSchema {
39    type Error = crate::Error;
40
41    fn try_from(value: &MarkedYaml) -> Result<IntegerSchema> {
42        if let YamlData::Mapping(mapping) = &value.data {
43            Ok(IntegerSchema::try_from(mapping)?)
44        } else {
45            Err(expected_mapping!(value))
46        }
47    }
48}
49
50impl TryFrom<&AnnotatedMapping<'_, MarkedYaml<'_>>> for IntegerSchema {
51    type Error = crate::Error;
52
53    fn try_from(mapping: &AnnotatedMapping<'_, MarkedYaml<'_>>) -> crate::Result<Self> {
54        let mut integer_schema = IntegerSchema::from_base(BaseSchema::try_from(mapping)?);
55        for (key, value) in mapping.iter() {
56            if let YamlData::Value(Scalar::String(key)) = &key.data {
57                if integer_schema.base.handle_key_value(key, value)?.is_none() {
58                    match key.as_ref() {
59                        "minimum" => {
60                            integer_schema.minimum = Some(value.try_into()?);
61                        }
62                        "maximum" => {
63                            integer_schema.maximum = Some(value.try_into()?);
64                        }
65                        "exclusiveMinimum" => {
66                            integer_schema.exclusive_minimum = Some(value.try_into()?);
67                        }
68                        "exclusiveMaximum" => {
69                            integer_schema.exclusive_maximum = Some(value.try_into()?);
70                        }
71                        "multipleOf" => {
72                            integer_schema.multiple_of = Some(value.try_into()?);
73                        }
74                        // Maybe this should be handled by the base schema?
75                        "type" => {
76                            if let YamlData::Value(Scalar::String(s)) = &value.data {
77                                if s != "integer" {
78                                    return Err(unsupported_type!(
79                                        "Expected type: integer, but got: {}",
80                                        s
81                                    ));
82                                }
83                            } else {
84                                return Err(expected_type_is_string!(value));
85                            }
86                        }
87                        _ => unimplemented!("Unsupported key for type: integer: {}", key),
88                    }
89                }
90            } else {
91                return Err(generic_error!(
92                    "{} Expected string key, got {:?}",
93                    format_marker(&key.span.start),
94                    key
95                ));
96            }
97        }
98        Ok(integer_schema)
99    }
100}
101
102impl std::fmt::Display for IntegerSchema {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(f, "Integer {self:?}")
105    }
106}
107
108impl Validator for IntegerSchema {
109    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
110        let data = &value.data;
111        let enum_values = self.base.r#enum.as_ref().map(|r#enum| {
112            r#enum
113                .iter()
114                .filter_map(|v| {
115                    if let ConstValue::Number(Number::Integer(i)) = v {
116                        Some(*i)
117                    } else {
118                        None
119                    }
120                })
121                .collect::<Vec<i64>>()
122        });
123        if let saphyr::YamlData::Value(scalar) = data {
124            if let saphyr::Scalar::Integer(i) = scalar {
125                self.validate_integer(context, &enum_values, value, *i);
126            } else if let saphyr::Scalar::FloatingPoint(o) = scalar {
127                let f = o.into_inner();
128                if f.fract() == 0.0 {
129                    self.validate_integer(context, &enum_values, value, f as i64);
130                } else {
131                    context.add_error(value, format!("Expected an integer, but got: {data:?}"));
132                }
133            } else {
134                context.add_error(value, format!("Expected a number, but got: {data:?}"));
135            }
136        } else {
137            context.add_error(value, format!("Expected a scalar value, but got: {data:?}"));
138        }
139        if !context.errors.borrow().is_empty() {
140            fail_fast!(context)
141        }
142        Ok(())
143    }
144}
145
146impl IntegerSchema {
147    pub fn from_base(base: BaseSchema) -> Self {
148        Self {
149            base,
150            ..Default::default()
151        }
152    }
153
154    fn validate_integer(
155        &self,
156        context: &Context,
157        enum_values: &Option<Vec<i64>>,
158        value: &MarkedYaml,
159        i: i64,
160    ) {
161        if let Some(exclusive_min) = self.exclusive_minimum {
162            match exclusive_min {
163                Number::Integer(exclusive_min) => {
164                    if i <= exclusive_min {
165                        context.add_error(
166                            value,
167                            format!("Number must be greater than {exclusive_min}"),
168                        );
169                    }
170                }
171                Number::Float(exclusive_min) => {
172                    if (i as f64).partial_cmp(&exclusive_min) != Some(Ordering::Greater) {
173                        context.add_error(
174                            value,
175                            format!("Number must be greater than {exclusive_min}"),
176                        );
177                    }
178                }
179            }
180        } else if let Some(minimum) = self.minimum {
181            match minimum {
182                Number::Integer(min) => {
183                    if i <= min {
184                        context.add_error(
185                            value,
186                            format!("Number must be greater than or equal to {min}"),
187                        );
188                    }
189                }
190                Number::Float(min) => {
191                    let cmp = (i as f64).partial_cmp(&min);
192                    if cmp != Some(Ordering::Less) && cmp != Some(Ordering::Equal) {
193                        context.add_error(
194                            value,
195                            format!("Number must be greater than or equal to {min}"),
196                        );
197                    }
198                }
199            }
200        }
201
202        if let Some(exclusive_max) = self.exclusive_maximum {
203            match exclusive_max {
204                Number::Integer(exclusive_max) => {
205                    if i >= exclusive_max {
206                        context.add_error(
207                            value,
208                            format!("Number must be less than than {exclusive_max}"),
209                        );
210                    }
211                }
212                Number::Float(exclusive_max) => {
213                    if (i as f64).partial_cmp(&exclusive_max) != Some(Ordering::Less) {
214                        context.add_error(
215                            value,
216                            format!("Number must be less than than {exclusive_max}"),
217                        );
218                    }
219                }
220            }
221        } else if let Some(maximum) = self.maximum {
222            match maximum {
223                Number::Integer(max) => {
224                    if i >= max {
225                        context.add_error(
226                            value,
227                            format!("Number must be less than or equal to {max}"),
228                        );
229                    }
230                }
231                Number::Float(max) => {
232                    let cmp = (i as f64).partial_cmp(&max);
233                    if cmp != Some(Ordering::Greater) && cmp != Some(Ordering::Equal) {
234                        context.add_error(
235                            value,
236                            format!("Number must be less than or equal to {max}"),
237                        );
238                    }
239                }
240            }
241        }
242
243        if let Some(multiple_of) = self.multiple_of {
244            match multiple_of {
245                Number::Integer(multiple) => {
246                    if i % multiple != 0 {
247                        context
248                            .add_error(value, format!("Number is not a multiple of {multiple}!"));
249                    }
250                }
251                Number::Float(multiple) => {
252                    if (i as f64) % multiple != 0.0 {
253                        context
254                            .add_error(value, format!("Number is not a multiple of {multiple}!"));
255                    }
256                }
257            }
258        }
259        if let Some(enum_values) = enum_values
260            && !enum_values.contains(&i)
261        {
262            context.add_error(value, format!("Number is not in enum: {enum_values:?}"));
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use saphyr::LoadableYamlNode;
270
271    use super::*;
272
273    #[test]
274    fn test_integer_schema_against_string() {
275        let schema = IntegerSchema::default();
276        let context = Context::new(true);
277        let docs = saphyr::MarkedYaml::load_from_str("foo").unwrap();
278        let result = schema.validate(&context, docs.first().unwrap());
279        assert!(result.is_err());
280        let errors = context.errors.borrow();
281        assert!(!errors.is_empty());
282        let first_error = errors.first().unwrap();
283        assert_eq!(
284            first_error.error,
285            "Expected a number, but got: Value(String(\"foo\"))"
286        );
287    }
288
289    #[test]
290    fn test_integer_schema_with_description() {
291        let yaml = r#"
292        type: integer
293        description: The description
294        "#;
295        let marked_yaml = MarkedYaml::load_from_str(yaml).unwrap();
296        let integer_schema = IntegerSchema::try_from(marked_yaml.first().unwrap()).unwrap();
297        assert_eq!(
298            integer_schema.base.description,
299            Some("The description".to_string())
300        );
301    }
302}