shape-schema 0.2.1

Schema-by-example validation: your schema looks like your data. The Rust port of shape, held to the canonical TypeScript by a shared corpus.
Documentation
//! Errors: one per failing value, with the text TypeScript renders for it.

use crate::context::{join_path, PathPart};
use crate::node::Kind;
use crate::value::{value_kind, value_to_string, Map, Value};
use std::fmt;

/// The marks TypeScript gives its structural errors.
pub const MARK_OBJECT_REQUIRED: i64 = 1010;
pub const MARK_OBJECT_TYPE: i64 = 1020;
pub const MARK_ARRAY_REQUIRED: i64 = 1030;
pub const MARK_ARRAY_TYPE: i64 = 1040;
pub const MARK_CHECK_TYPE: i64 = 1045;
pub const MARK_SCALAR_TYPE: i64 = 1050;
pub const MARK_SCALAR_REQUIRED: i64 = 1060;
pub const MARK_NEVER: i64 = 1070;
pub const MARK_REGEXP: i64 = 1045;
pub const MARK_UNDEF_REQUIRED: i64 = 1080;
pub const MARK_ARRAY_CLOSED: i64 = 1090;
pub const MARK_OBJECT_CLOSED: i64 = 1100;
pub const MARK_CUSTOM_CHECK_ERR: i64 = 2010;
pub const MARK_CUSTOM_CHECK_TEXT: i64 = 4000;

/// The why codes.
pub const WHY_TYPE: &str = "type";
pub const WHY_REQUIRED: &str = "required";
pub const WHY_CLOSED: &str = "closed";
pub const WHY_CHECK: &str = "check";
pub const WHY_NEVER: &str = "never";
pub const WHY_REGEXP: &str = "regexp";

/// One validation failure.
#[derive(Clone, Debug, Default)]
pub struct FieldError {
    /// The dotted path of the failing value.
    pub path: String,
    /// The path as parts: array indices as indices, keys as keys.
    pub path_arr: Vec<PathPart>,
    /// The immediate key or index.
    pub key: String,
    /// The kind of the node that ran the check.
    pub kind: Kind,
    /// The failing value.
    pub value: Value,
    pub why: String,
    pub mark: i64,
    /// The message.
    pub text: String,
    /// Extra context of a custom check.
    pub args: Map,
    /// The name of the failing check.
    pub check: String,
    pub(crate) parent_arr: bool,
    pub(crate) absent: bool,
    pub(crate) regexp_src: String,
    pub(crate) plural: bool,
    pub(crate) terse: bool,
}

impl fmt::Display for FieldError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if !self.text.is_empty() {
            return f.write_str(&self.text);
        }
        if self.path.is_empty() {
            f.write_str(&self.why)
        } else {
            write!(f, "{}: {}", self.path, self.why)
        }
    }
}

/// The errors of one validation.
#[derive(Clone, Debug, Default)]
pub struct ValidationError {
    pub issues: Vec<FieldError>,
    /// A terse collector counts rather than stores.
    pub(crate) terse: bool,
    pub(crate) n: usize,
}

impl ValidationError {
    pub(crate) fn add(&mut self, err: FieldError) {
        if self.terse {
            self.n += 1;
            return;
        }
        self.issues.push(err);
    }

    pub fn has_any(&self) -> bool {
        self.n > 0 || !self.issues.is_empty()
    }

    /// How many errors were added, counted or stored.
    pub(crate) fn count(&self) -> usize {
        self.n + self.issues.len()
    }
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let parts: Vec<String> = self.issues.iter().map(|e| e.to_string()).collect();
        f.write_str(&parts.join("\n"))
    }
}

impl std::error::Error for ValidationError {}

/// Where an error happened: what `make_err` needs of the state.
pub(crate) struct At<'a> {
    pub path: &'a [PathPart],
    pub key: &'a str,
    pub kind: Kind,
    pub value: &'a Value,
    pub parent_arr: bool,
    pub absent: bool,
    pub check: &'a str,
    pub regexp_src: Option<String>,
    pub terse: bool,
}

/// An error at `at`, with the default text for `why` or the `text` given
/// (`$PATH` and `$VALUE` expanded).
pub(crate) fn make_err(at: &At<'_>, why: &str, mark: i64, text: &str) -> FieldError {
    let why = if why.is_empty() { WHY_CHECK } else { why };
    let mark = if mark == 0 {
        MARK_CUSTOM_CHECK_TEXT
    } else {
        mark
    };
    if at.terse {
        // Counted, never read: nothing of the key, the value or the
        // check is kept.
        return FieldError {
            kind: at.kind,
            mark,
            terse: true,
            ..Default::default()
        };
    }
    let path = join_path(at.path);
    let mut err = FieldError {
        path,
        path_arr: at.path.to_vec(),
        key: at.key.to_string(),
        kind: at.kind,
        value: at.value.clone(),
        why: why.to_string(),
        mark,
        check: at.check.to_string(),
        parent_arr: at.parent_arr,
        absent: at.absent,
        regexp_src: at.regexp_src.clone().unwrap_or_default(),
        ..Default::default()
    };
    err.text = if text.is_empty() {
        default_err_text(&err)
    } else {
        expand_err_text(text, &err.path, at.value, at.absent)
    };
    err
}

/// Expand a message template: `$PATH` and `$VALUE`, a missing value as
/// "undefined".
pub(crate) fn expand_err_text(text: &str, path: &str, val: &Value, absent: bool) -> String {
    let valstr = if absent {
        "undefined".to_string()
    } else {
        value_to_string(val)
    };
    text.replace("$PATH", path).replace("$VALUE", &valstr)
}

/// The text of a structural error, as TypeScript renders it.
pub(crate) fn default_err_text(e: &FieldError) -> String {
    let (valstr, valkind) = if e.absent {
        ("undefined".to_string(), "value")
    } else {
        (value_to_string(&e.value), value_kind(&e.value))
    };
    // "index" when the value renders as an array or its parent is one.
    let propkind = if e.parent_arr || valstr.starts_with('[') {
        "index"
    } else {
        "property"
    };
    // A property that is not there is named, not rendered.
    if e.why == WHY_REQUIRED && e.absent && !e.path.is_empty() {
        let noun = if propkind == "index" {
            "element"
        } else {
            "property"
        };
        return format!(
            "Validation failed for {} \"{}\" because the {} is missing.",
            propkind, e.path, noun
        );
    }
    // Written once into one buffer: the head, then the clause of the why.
    let mut out = String::with_capacity(96 + e.path.len() + valstr.len() + e.key.len());
    out.push_str("Validation failed for ");
    if !e.path.is_empty() {
        out.push_str(propkind);
        out.push_str(" \"");
        out.push_str(&e.path);
        out.push_str("\" with ");
    }
    out.push_str(valkind);
    out.push_str(" \"");
    out.push_str(&valstr);
    out.push_str("\" because ");
    match e.why.as_str() {
        WHY_TYPE => {
            out.push_str("the ");
            out.push_str(valkind);
            out.push_str(" is not of type ");
            out.push_str(e.kind.as_str());
            out.push('.');
        }
        WHY_REQUIRED => {
            if matches!(e.value, Value::Str(ref s) if s.is_empty()) {
                out.push_str("an empty string is not allowed.");
            } else if e.value.is_null() || e.absent {
                out.push_str("the value is required.");
            } else {
                out.push_str("the ");
                out.push_str(valkind);
                out.push_str(" is required.");
            }
        }
        WHY_CLOSED => {
            let (noun, verb) = if e.plural {
                ("properties", "are")
            } else {
                (propkind, "is")
            };
            out.push_str("the ");
            out.push_str(noun);
            out.push_str(" \"");
            out.push_str(&e.key);
            out.push_str("\" ");
            out.push_str(verb);
            out.push_str(" not allowed.");
        }
        WHY_NEVER => out.push_str("no value is allowed."),
        WHY_REGEXP => {
            out.push_str("the ");
            out.push_str(valkind);
            out.push_str(" did not match ");
            out.push_str(&e.regexp_src);
            out.push('.');
        }
        _ => {
            let name = if e.check.is_empty() {
                e.why.as_str()
            } else {
                e.check.as_str()
            };
            out.push_str("check \"");
            out.push_str(name);
            out.push_str("\" failed.");
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn at<'a>(path: &'a [PathPart], value: &'a Value, absent: bool) -> At<'a> {
        At {
            path,
            key: "k",
            kind: Kind::String,
            value,
            parent_arr: false,
            absent,
            check: "",
            regexp_src: None,
            terse: false,
        }
    }

    #[test]
    fn texts() {
        let p = vec![PathPart::Key("a".into())];
        let v = Value::from(1);
        let e = make_err(&at(&p, &v, false), WHY_TYPE, MARK_SCALAR_TYPE, "");
        assert_eq!(e.text, "Validation failed for property \"a\" with number \"1\" because the number is not of type string.");
        assert_eq!(e.to_string(), e.text);
        let e = make_err(&at(&p, &Value::Undefined, true), WHY_REQUIRED, 0, "");
        assert_eq!(
            e.text,
            "Validation failed for property \"a\" because the property is missing."
        );
        assert_eq!(e.mark, MARK_CUSTOM_CHECK_TEXT);
        let e = make_err(&at(&[], &Value::Undefined, true), WHY_REQUIRED, 1, "");
        assert_eq!(
            e.text,
            "Validation failed for value \"undefined\" because the value is required."
        );
        let e = make_err(&at(&p, &Value::from(""), false), WHY_REQUIRED, 1, "");
        assert_eq!(e.text, "Validation failed for property \"a\" with string \"\" because an empty string is not allowed.");
        let e = make_err(&at(&p, &Value::Null, false), WHY_REQUIRED, 1, "");
        assert_eq!(e.text, "Validation failed for property \"a\" with value \"null\" because the value is required.");
        let e = make_err(&at(&p, &Value::from(2), false), WHY_REQUIRED, 1, "");
        assert_eq!(e.text, "Validation failed for property \"a\" with number \"2\" because the number is required.");
        let mut e = make_err(&at(&p, &v, false), WHY_CLOSED, 1, "");
        e.plural = true;
        e.key = "x, y".into();
        assert_eq!(default_err_text(&e), "Validation failed for property \"a\" with number \"1\" because the properties \"x, y\" are not allowed.");
        e.plural = false;
        assert_eq!(default_err_text(&e), "Validation failed for property \"a\" with number \"1\" because the property \"x, y\" is not allowed.");
        let e = make_err(&at(&p, &v, false), WHY_NEVER, 1, "");
        assert_eq!(
            e.text,
            "Validation failed for property \"a\" with number \"1\" because no value is allowed."
        );
        let mut a = at(&p, &v, false);
        a.regexp_src = Some("/x/".into());
        let e = make_err(&a, WHY_REGEXP, 1, "");
        assert_eq!(e.text, "Validation failed for property \"a\" with number \"1\" because the number did not match /x/.");
        let mut a = at(&p, &v, false);
        a.check = "Min";
        let e = make_err(&a, "", 1, "");
        assert_eq!(
            e.text,
            "Validation failed for property \"a\" with number \"1\" because check \"Min\" failed."
        );
        let e = make_err(&at(&p, &v, false), "why", 1, "");
        assert_eq!(
            e.text,
            "Validation failed for property \"a\" with number \"1\" because check \"why\" failed."
        );
        let e = make_err(
            &at(&p, &v, false),
            "why",
            1,
            "Value \"$VALUE\" at \"$PATH\" is bad.",
        );
        assert_eq!(e.text, "Value \"1\" at \"a\" is bad.");
        assert_eq!(
            expand_err_text("$VALUE", "", &Value::Null, true),
            "undefined"
        );
        let arr = Value::Arr(vec![Value::from(1)]);
        let e = make_err(&at(&p, &arr, false), WHY_TYPE, 1, "");
        assert_eq!(e.text, "Validation failed for index \"a\" with array \"[1]\" because the array is not of type string.");
        let mut a = at(&p, &v, false);
        a.parent_arr = true;
        a.absent = true;
        let e = make_err(&a, WHY_REQUIRED, 1, "");
        assert_eq!(
            e.text,
            "Validation failed for index \"a\" because the element is missing."
        );
        let mut a = at(&p, &v, false);
        a.terse = true;
        let e = make_err(&a, WHY_TYPE, 1, "");
        assert!(e.terse && e.text.is_empty() && e.path.is_empty());
        let plain = FieldError {
            path: "a".into(),
            why: "w".into(),
            ..Default::default()
        };
        assert_eq!(plain.to_string(), "a: w");
        let plain = FieldError {
            why: "w".into(),
            ..Default::default()
        };
        assert_eq!(plain.to_string(), "w");
    }

    #[test]
    fn collector() {
        let mut v = ValidationError::default();
        assert!(!v.has_any());
        v.add(FieldError {
            text: "a".into(),
            ..Default::default()
        });
        v.add(FieldError {
            text: "b".into(),
            ..Default::default()
        });
        assert_eq!(v.to_string(), "a\nb");
        let mut t = ValidationError {
            terse: true,
            ..Default::default()
        };
        t.add(FieldError::default());
        t.add(FieldError::default());
        assert!(t.has_any() && t.issues.is_empty() && t.n == 2);
        let _: &dyn std::error::Error = &v;
    }
}