Repository: shuttle-hq/synth (rust)

Issue:
Unhelpful error when messing up `format` generator structure
**Describe the bug**
If we miss one level of JSON nesting in `format`, we get an unhelpful error:

**To Reproduce**
Steps to reproduce the behavior:
1. Schema (if applicable)
```
{
    "type": "array",
    "length": 1,
    "content": {
        "type": "object",
        "s": {
            "type": "string",
            "format": "say my {name}",
            "arguments": {
                "name": "name"
            }
        }
    }
}
```
2. See error
```
Error: Unable to open the namespace

Caused by:
    0: at file 5_misformat/misformat.json
    1: Failed to parse collection
    2: invalid value: map, expected map with a single key at line 11 column 1
```

**Expected behavior**
```
Error: The `string:format` generator specified at line 11 column 1 needs one more level of indirection.
Instead of `"format": _, "arguments": { .. }` use `"format": { "format": _, "arguments": { .. } }`.
```

**Environment (please complete the following information):**
 - OS: any
 - Version: 0.5.6

Unhelpful error on mistyped faker generator
**Describe the bug**
The error if one mistypes a `faker` generator is less than helpful:

**To Reproduce**
Steps to reproduce the behavior:
1. Schema (if applicable)
```
{
    "type": "array",
    "length":  1,
    "content": {
        "type": "object",
        "name": {
            "type": "string",
            "faker": "name"
        }
    }
}
```
2. See error
```
Error: Unable to open the namespace

Caused by:
    0: at file empty/empty.json
    1: Failed to parse collection
    2: invalid type: string "name", expected struct FakerContent at line 8 column 1
```

**Expected behavior**
```
The `string:faker` generator in line 8 column 1 is incorrectly specified.
Please use `{"type": "string", "faker": { "generator": "name" } }` instead.
```
(Bonus points for using the value of the `"faker"` entry in the suggestion)



Discussion:

Patch:
diff --git a/.github/workflows/synth-errors.yml b/.github/workflows/synth-errors.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/synth-errors.yml
@@ -0,0 +1,26 @@
+name: synth-errors
+
+on:
+  push:
+    branches: [master]
+    paths: ["**/*.rs", "synth/testing_harness/errors/**"]
+  pull_request:
+    branches: [master]
+    paths: ["**/*.rs", "synth/testing_harness/errors/**"]
+
+  workflow_dispatch:
+
+env:
+  RUSTFLAGS: "-D warnings"
+
+jobs:
+  e2e_tests_postgres:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          toolchain: nightly
+      - run: cargo +nightly install --debug --path=synth
+      - run: ./e2e.sh
+        working-directory: synth/testing_harness/errors
diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2195,6 +2195,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "paste"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0744126afe1a6dd7f394cb50a716dbe086cb06e255e53d8d0185d82828358fb5"
+
 [[package]]
 name = "pbkdf2"
 version = "0.7.5"
@@ -3276,6 +3282,7 @@ dependencies = [
  "log",
  "num",
  "num_cpus",
+ "paste",
  "rand 0.8.4",
  "rand_regex",
  "regex",
diff --git a/core/Cargo.toml b/core/Cargo.toml
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -37,3 +37,4 @@ bloomfilter = "1.0.5"
 dynfmt = { version = "0.1.5", features = [ "curly" ] }
 sqlx = { version = "0.5.7", features = [ "postgres", "mysql", "runtime-async-std-native-tls", "decimal", "chrono" ] }
 uriparse = "0.6.3"
+paste = "1.0"
diff --git a/core/src/schema/content/array.rs b/core/src/schema/content/array.rs
--- a/core/src/schema/content/array.rs
+++ b/core/src/schema/content/array.rs
@@ -1,15 +1,138 @@
 use super::prelude::*;
 use crate::graph::prelude::content::number::number_content::U64;
-use crate::schema::{NumberContent, RangeStep};
+use crate::graph::prelude::VariantContent;
+use crate::schema::{number_content, NumberContent, RangeStep};
+use serde::de;
+use std::fmt;
 
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash)]
+#[derive(Debug, Serialize, Clone, PartialEq, Hash)]
 #[serde(rename_all = "snake_case")]
 #[serde(deny_unknown_fields)]
 pub struct ArrayContent {
+    #[serde(default)]
     pub length: Box<Content>,
     pub content: Box<Content>,
 }
 
+lazy_static! {
+    static ref NULL_VARIANT: VariantContent =
+        VariantContent::new(Content::Null(prelude::NullContent {}));
+}
+
+impl<'de> Deserialize<'de> for ArrayContent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(field_identifier, rename_all = "lowercase")]
+        enum Field {
+            Length,
+            Content,
+        }
+
+        struct ArrayVisitor;
+
+        impl<'de> Visitor<'de> for ArrayVisitor {
+            type Value = ArrayContent;
+
+            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                formatter.write_str("a object with a 'content' and 'length' value")
+            }
+
+            fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
+            where
+                A: de::MapAccess<'de>,
+            {
+                let mut length = None;
+                let mut content = None;
+
+                while let Some(key) = access.next_key()? {
+                    match key {
+                        Field::Length => {
+                            if length.is_some() {
+                                return Err(de::Error::duplicate_field("length"));
+                            }
+
+                            length = Some(access.next_value()?);
+                        }
+                        Field::Content => {
+                            if content.is_some() {
+                                return Err(de::Error::duplicate_field("content"));
+                            }
+
+                            content = Some(access.next_value()?);
+                        }
+                    }
+                }
+
+                let length = length.ok_or_else(|| de::Error::missing_field("length"))?;
+                let content = content.ok_or_else(|| de::Error::missing_field("content"))?;
+
+                match length {
+                    Content::Number(NumberContent::U64(number_content::U64::Range(r))) => {
+                        if r.high.is_none() {
+                            return Err(A::Error::custom(
+                                "missing high value for array length range"
+                            ));
+                        }
+                    }
+                    // Default for ranges
+                    Content::Number(NumberContent::U32(number_content::U32::Range(r))) => {
+                        if r.high.is_none() {
+                            return Err(A::Error::custom(
+                                "missing high value for array length range"
+                            ));
+                        }
+                    }
+                    Content::Number(NumberContent::U64(_)) => {},
+                    // Default for negative numbers
+                    Content::Number(NumberContent::I64(number_content::I64::Constant(n))) => {
+                        is_positive(n).map_err(A::Error::custom)?
+                    }
+                    Content::Number(NumberContent::I32(number_content::I32::Range(r))) => {
+                        if r.high.is_none() {
+                            return Err(A::Error::custom(
+                                "missing high value for array length range"
+                            ));
+                        }
+
+                        is_positive(
+                            r.low .unwrap_or_default() .into(),
+                        ).map_err(A::Error::custom)?
+                    },
+                    Content::SameAs(_) => {},
+                    Content::Null(_) => return Err(de::Error::custom("array length is missing. Try adding '\"length\": [number]' to the array type where '[number]' is a positive integer")),
+                    Content::Empty(_) => return Err(de::Error::custom("array length is not a constant or number type. Try replacing the '\"length\": {}' with '\"length\": [number]' where '[number]' is a positive integer")),
+                    Content::OneOf(ref one) => if one.variants.iter().any(|variant| variant == &*NULL_VARIANT) {
+                        return Err(de::Error::custom("cannot use 'one_of' with a 'null' variant nor '\"optional\": true' in array length"));
+                    },
+                    _ => {
+                        return Err(de::Error::custom(
+                            format!(
+                                "cannot use {} as an array length",
+                                length
+                            )
+                        ));
+                    }
+                }
+
+                if let Content::Empty(_) = content {
+                    return Err(de::Error::custom("array content is missing. Try replacing the '\"content\": {}' with '\"content\": { \"type\": \"object\" }'"));
+                }
+
+                Ok(ArrayContent {
+                    length: Box::new(length),
+                    content: Box::new(content),
+                })
+            }
+        }
+
+        const FIELDS: &[&str] = &["length", "content"];
+        deserializer.deserialize_struct("ArrayContent", FIELDS, ArrayVisitor)
+    }
+}
+
 impl ArrayContent {
     pub fn from_content_default_length(content: Content) -> Self {
         Self {
@@ -29,6 +152,14 @@ impl Compile for ArrayContent {
     }
 }
 
+fn is_positive(i: i64) -> Result<()> {
+    if i.is_negative() {
+        return Err(anyhow!("cannot have a negative array length"));
+    }
+
+    Ok(())
+}
+
 impl Find<Content> for ArrayContent {
     fn project<I, R>(&self, mut reference: Peekable<I>) -> Result<&Content>
     where
@@ -62,3 +193,315 @@ impl Find<Content> for ArrayContent {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::schema::content::Content;
+    use paste::paste;
+
+    macro_rules! supported_length_tests {
+        ($($name:ident: {$($schema:tt)*},)*) => {
+        $(paste!{
+            #[test]
+            fn [<supported_length_ $name>]() {
+                let _schema: Content = schema!({$($schema)*});
+            }
+        })*
+        }
+    }
+
+    supported_length_tests! {
+        default: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object"
+            }
+        },
+        u64_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "u64",
+                "constant": 3
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        default_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "range": {
+                    "low": 5,
+                    "high": 150
+                }
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        u64_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "u64",
+                "range": {
+                    "low": 7,
+                    "high": 8
+                }
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+    }
+
+    macro_rules! negative_length_tests {
+        ($($name:ident: {$($schema:tt)*},)*) => {
+        $(paste!{
+            #[test]
+            #[should_panic(expected = "cannot have a negative array length")]
+            fn [<negative_length_ $name>]() {
+                let _schema: Content = schema!({$($schema)*});
+            }
+        })*
+        }
+    }
+
+    negative_length_tests! {
+        default: {
+            "type": "array",
+            "length": -1,
+            "content": {
+                "type": "object"
+            }
+        },
+        default_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "range": {
+                    "low": -5,
+                    "high": 40
+                }
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+    }
+
+    macro_rules! unsupported_length_tests {
+        ($($name:ident: {$($schema:tt)*},)*) => {
+        $(paste!{
+            #[test]
+            #[should_panic(expected = "cannot use")]
+            fn [<unsupported_length_ $name>]() {
+                let _schema: Content = schema!({$($schema)*});
+            }
+        })*
+        }
+    }
+
+    unsupported_length_tests! {
+        i32_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "i32",
+                "constant": 10
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        u32_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "u32",
+                "constant": 8
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        i64_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "i64",
+                "range": {}
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        default_float_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "constant": -5.0
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        f64_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "f64",
+                "constant": -5.0
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        f64_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "f64",
+                "range": {}
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        f32_constant: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "f32",
+                "constant": 3.0
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        f32_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "f32",
+                "range": {}
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+    }
+
+    macro_rules! missing_high_length_tests {
+        ($($name:ident: {$($schema:tt)*},)*) => {
+        $(paste!{
+            #[test]
+            #[should_panic(expected = "missing high value for array length")]
+            fn [<missing_high_length_ $name>]() {
+                let _schema: Content = schema!({$($schema)*});
+            }
+        })*
+        }
+    }
+
+    missing_high_length_tests! {
+        u64_default: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "u64",
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        u64_range: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "subtype": "u64",
+                "range": {}
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+    }
+
+    #[test]
+    #[should_panic(expected = "missing field `length`")]
+    fn missing_array_length() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "content": {
+                "type": "object"
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(
+        expected = "cannot use 'one_of' with a 'null' variant nor '\\\"optional\\\": true' in array length"
+    )]
+    fn optional_array_length() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": {
+                "type": "number",
+                "constant": 5,
+                "optional": true
+            },
+            "content": {
+                "type": "object"
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(
+        expected = "cannot use 'one_of' with a 'null' variant nor '\\\"optional\\\": true' in array length"
+    )]
+    fn one_of_null_array_length() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": {
+                "type": "one_of",
+                "variants": [
+                    {
+                        "type": "null",
+                    },
+                    {
+                        "type": "number",
+                        "constant": 5
+                    }
+                ]
+            },
+            "content": {
+                "type": "object"
+            }
+        });
+    }
+
+    #[test]
+    fn one_of_array_length() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": {
+                "type": "one_of",
+                "variants": [{
+                    "type": "number",
+                    "constant": 3
+                }, {
+                    "type": "number",
+                    "constant": 5
+                }]
+            },
+            "content": {
+                "type": "object"
+            }
+        });
+    }
+}
diff --git a/core/src/schema/content/mod.rs b/core/src/schema/content/mod.rs
--- a/core/src/schema/content/mod.rs
+++ b/core/src/schema/content/mod.rs
@@ -13,8 +13,10 @@
 //! - Things that belong to those submodules that also need to be exposed
 //!   to other parts of `synth` should be re-exported here.
 
+use std::collections::BTreeMap;
 use std::hash::{Hash, Hasher};
 
+use paste::paste;
 use serde::{de::IntoDeserializer, Deserialize, Serialize};
 use serde_json::Value;
 
@@ -104,6 +106,9 @@ pub struct SameAsContent {
 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash)]
 pub struct NullContent;
 
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash)]
+pub struct EmptyContent;
+
 #[derive(Serialize, Deserialize, Debug)]
 pub struct ContentLabels {
     #[serde(default)]
@@ -137,11 +142,32 @@ impl ContentLabels {
     }
 }
 
+lazy_static! {
+    static ref UNEXPECTED: BTreeMap<&'static str, Vec<&'static str>> = {
+        let mut m = BTreeMap::new();
+        m.insert("arguments", vec!["format"]);
+        m.insert("low", vec!["range"]);
+        m.insert("high", vec!["range"]);
+        m.insert("step", vec!["range"]);
+        m.insert("include_high", vec!["range"]);
+        m.insert("include_low", vec!["range"]);
+        m.insert("start_at", vec!["id"]);
+        m.insert("start", vec!["incrementing", "poisson", "cyclical"]);
+        m.insert("increment", vec!["incrementing"]);
+        m.insert("rate", vec!["poisson"]);
+        m.insert("min_rate", vec!["cyclical"]);
+        m.insert("max_rate", vec!["cyclical"]);
+        m.insert("period", vec!["cyclical"]);
+        m.insert("series", vec!["zip"]);
+        m
+    };
+}
+
 macro_rules! content {
     {
         labels: $labels:ty,
         variants: {
-            $($name:ident($variant:ty)$(,)?)+
+            $($name:ident($variant:ty) => $msg:tt,)+
         }
     } => {
         #[derive(Debug, Serialize, Clone, PartialEq, Hash)]
@@ -192,6 +218,27 @@ macro_rules! content {
                             let value = map.next_value()?;
                             out.insert(key, value);
                         }
+
+                        if out.is_empty() {
+                            out.insert("type".to_string(), serde_json::Value::String("empty".to_string()));
+                        } else if out.len() == 1 && out.contains_key("type") {
+                            paste! {
+                            match out.get("type").unwrap().as_str() {
+                                $(
+                                    Some(stringify!([<$name:snake>])) => generator_field_error!($name, $msg),
+                                )*
+                                None | Some(_) => {}
+                            }
+                            }
+                        }
+
+                        for key in out.keys() {
+                            if let Some(parent) = UNEXPECTED.get(key as &str) {
+                                let parents = parent.iter().map(|p| format!("`{}`", p)).collect::<Vec<String>>().join(", ");
+                                return Err(A::Error::custom(format!("`{}` is expected to be a field of {}", key, parents)));
+                            }
+                        }
+
                         let __ContentWithLabels { labels, content } = __ContentWithLabels::deserialize(out.into_deserializer()).map_err(A::Error::custom)?;
                         match content {
                             $(
@@ -242,23 +289,34 @@ macro_rules! content {
         }
     }
 }
+macro_rules! generator_field_error {
+    ($name:ident, None) => {
+        {}
+    };
+    ($name:ident, $msg:tt) => {
+        paste! {
+            return Err(A::Error::custom(concat!("`", stringify!([<$name:snake>]), "` generator is ", $msg)))
+        }
+    }
+}
 
 content! {
     labels: ContentLabels,
     variants: {
-        Null(NullContent),
-        Bool(BoolContent),
-        Number(NumberContent),
-        String(StringContent),
-        DateTime(DateTimeContent),
-        Array(ArrayContent),
-        Object(ObjectContent),
-        SameAs(SameAsContent),
-        OneOf(OneOfContent),
-        Series(SeriesContent),
-        Unique(UniqueContent),
-        Hidden(HiddenContent),
-        Datasource(DatasourceContent),
+        Null(NullContent) => None,
+        Bool(BoolContent) => "missing a subtype. Try adding `constant`, or `frequency`",
+        Number(NumberContent) => "missing a subtype. Try adding `constant`, `range`, or `id`",
+        String(StringContent) => "missing a subtype. Try adding `pattern`, `faker`, `categorical`, `serialized`, `uuid`, `truncated`, or `format`",
+        DateTime(DateTimeContent) => "missing a `format` field",
+        Array(ArrayContent) => "missing a `length` and `content` field",
+        Object(ObjectContent) => None,
+        SameAs(SameAsContent) => "missing a `ref` field",
+        OneOf(OneOfContent) => "missing a `variants` field",
+        Series(SeriesContent) => "missing a variant. Try adding `incrementing`, `poisson`, `cyclical`, or `zip`",
+        Unique(UniqueContent) => "missing a `content` field",
+        Datasource(DatasourceContent) => "missing a `path` field",
+        Hidden(HiddenContent) => "missing a `content` field",
+        Empty(EmptyContent) => None,
     }
 }
 
@@ -452,6 +510,7 @@ impl Content {
             Content::Unique(_) => "unique".to_string(),
             Content::Hidden(_) => "hidden".to_string(),
             Content::Datasource(_) => "datasource".to_string(),
+            Content::Empty(_) => "empty".to_string(),
         }
     }
 }
@@ -582,6 +641,7 @@ impl Compile for Content {
             Self::Hidden(hidden_content) => hidden_content.compile(compiler),
             Self::Null(_) => Ok(Graph::null()),
             Self::Datasource(datasource) => datasource.compile(compiler),
+            Self::Empty(_) => Err(anyhow!("unexpected empty object")),
         }
     }
 }
@@ -637,6 +697,7 @@ impl Default for Weight {
 #[cfg(test)]
 pub mod tests {
     use super::*;
+    use paste::paste;
 
     lazy_static! {
         pub static ref USER_SCHEMA: Content = schema!({
@@ -836,4 +897,371 @@ pub mod tests {
             "end": "2020-11-05T09:53:10+0000"
         });
     }
+
+    #[test]
+    #[should_panic(
+        expected = "`string` generator is missing a subtype. Try adding `pattern`, `faker`, `categorical`, `serialized`, `uuid`, `truncated`, or `format`"
+    )]
+    fn string_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "string"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(
+        expected = "`bool` generator is missing a subtype. Try adding `constant`, or `frequency`"
+    )]
+    fn bool_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "b": {
+                    "type": "bool"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(
+        expected = "`number` generator is missing a subtype. Try adding `constant`, `range`, or `id`"
+    )]
+    fn number_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "n": {
+                    "type": "number"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`date_time` generator is missing a `format` field")]
+    fn date_time_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "dt": {
+                    "type": "date_time"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`array` generator is missing a `length` and `content` field")]
+    fn array_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "a": {
+                    "type": "array"
+                }
+            }
+        });
+    }
+
+    #[test]
+    fn object_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "o": {
+                    "type": "object"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`same_as` generator is missing a `ref` field")]
+    fn same_as_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "sa": {
+                    "type": "same_as"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`one_of` generator is missing a `variants` field")]
+    fn one_of_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "oo": {
+                    "type": "one_of"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(
+        expected = "`series` generator is missing a variant. Try adding `incrementing`, `poisson`, `cyclical`, or `zip`"
+    )]
+    fn series_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`unique` generator is missing a `content` field")]
+    fn unique_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "u": {
+                    "type": "unique"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`datasource` generator is missing a `path` field")]
+    fn datasource_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "d": {
+                    "type": "datasource"
+                }
+            }
+        });
+    }
+
+    #[test]
+    #[should_panic(expected = "`hidden` generator is missing a `content` field")]
+    fn hidden_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "h": {
+                    "type": "hidden"
+                }
+            }
+        });
+    }
+
+    #[test]
+    fn null_missing_subtype() {
+        let _schema: Content = schema!({
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "n": {
+                    "type": "null"
+                }
+            }
+        });
+    }
+
+    macro_rules! unexpected_content_tests {
+        ($($name:ident: {$($schema:tt)*},)*) => {
+        $(paste!{
+            #[test]
+            #[should_panic(expected = "is expected to be a field of")]
+            fn [<unexpected_content_ $name>]() {
+                let _schema: Content = schema!({$($schema)*});
+            }
+        })*
+        }
+    }
+
+    unexpected_content_tests! {
+        format: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "string",
+                    "format": "Hello {name}",
+                    "arguments": {
+                        "name": "synth"
+                    }
+                }
+            }
+        },
+        low: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "low": 1
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        high: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "high": 10
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        step: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "step": 2
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        include_high: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "include_high": true
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        include_low: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "include_low": true
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        start_at: {
+            "type": "array",
+            "length": {
+                "type": "number",
+                "start_at": 5
+            },
+            "content": {
+                "type": "object"
+            }
+        },
+        start: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "start": "2021-02-01 09:00:00"
+                }
+            }
+        },
+        increment: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "increment": "1m"
+                }
+            }
+        },
+        rate: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "rate": "1m"
+                }
+            }
+        },
+        max_rate: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "max_rate": "1m"
+                }
+            }
+        },
+        min_rate: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "min_rate": "1m"
+                }
+            }
+        },
+        period: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "period": "1d"
+                }
+            }
+        },
+        series: {
+            "type": "array",
+            "length": 1,
+            "content": {
+                "type": "object",
+                "s": {
+                    "type": "series",
+                    "series": []
+                }
+            }
+        },
+    }
 }
diff --git a/core/src/schema/content/string.rs b/core/src/schema/content/string.rs
--- a/core/src/schema/content/string.rs
+++ b/core/src/schema/content/string.rs
@@ -163,7 +163,7 @@ impl Serialize for FakerContentArgument {
     }
 }
 
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash)]
+#[derive(Debug, Serialize, Clone, PartialEq, Hash)]
 pub struct FakerContent {
     pub generator: String,
     /// deprecated: Use FakerArgs::locale instead
@@ -180,6 +180,72 @@ impl FakerContent {
     }
 }
 
+impl<'de> Deserialize<'de> for FakerContent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(field_identifier, rename_all = "lowercase")]
+        enum Field {
+            Generator,
+            Locales,
+            #[serde(other)]
+            Unknown,
+        }
+        struct FakerVisitor;
+
+        impl<'de> Visitor<'de> for FakerVisitor {
+            type Value = FakerContent;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("'generator'")
+            }
+
+            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+            where
+                A: serde::de::MapAccess<'de>,
+            {
+                let mut generator = None;
+                let mut args = None;
+                while let Some(key) = map.next_key()? {
+                    match key {
+                        Field::Generator => {
+                            if generator.is_some() {
+                                return Err(A::Error::duplicate_field("generator"));
+                            }
+                            generator = Some(map.next_value()?);
+                        }
+                        Field::Locales => {
+                            if args.is_some() {
+                                return Err(A::Error::duplicate_field("locales"));
+                            }
+                            args = Some(map.next_value()?);
+                        }
+                        Field::Unknown => {}
+                    }
+                }
+                let generator = generator.ok_or_else(|| A::Error::missing_field("generator"))?;
+                let args = args.unwrap_or_default();
+                Ok(FakerContent {
+                    generator,
+                    args,
+                    locales: Vec::new(),
+                })
+            }
+
+            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+            where
+                E: serde::de::Error,
+            {
+                Err(E::custom(format!("`faker` is expected to have a `generator` field. Try '\"faker\": {{\"generator\": \"{}\"}}'", v)))
+            }
+        }
+
+        deserializer.deserialize_any(FakerVisitor)
+    }
+}
+
 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Hash)]
 #[serde(rename_all = "lowercase")]
 #[serde(tag = "serializer")]
@@ -290,3 +356,19 @@ impl Compile for StringContent {
         Ok(Graph::String(string_node))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::schema::content::Content;
+
+    #[test]
+    #[should_panic(
+        expected = "`faker` is expected to have a `generator` field. Try '\\\"faker\\\": {\\\"generator\\\": \\\"lastname\\\"}'"
+    )]
+    fn faker_missing_generator() {
+        let _schema: Content = schema!({
+            "type": "string",
+            "faker": "lastname"
+        });
+    }
+}
diff --git a/synth/Cargo.toml b/synth/Cargo.toml
--- a/synth/Cargo.toml
+++ b/synth/Cargo.toml
@@ -9,6 +9,7 @@ authors = [
 edition = "2021"
 homepage = "https://getsynth.com"
 license = "Apache-2.0"
+exclude = ["testing_harness/*"]
 
 [[bench]]
 name = "bench"


Test patch:
diff --git a/synth/testing_harness/errors/README.md b/synth/testing_harness/errors/README.md
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/README.md
@@ -0,0 +1,11 @@
+Integration Tests for Error Messages
+====================================
+
+This is an integration test that validates the synth generate command gives helpful error messages.
+
+# Requirements:
+- jq
+
+# Instructions
+
+To run this, execute `e2e.sh` script from the current directory. A non-zero return code denotes failure.
diff --git a/synth/testing_harness/errors/e2e.sh b/synth/testing_harness/errors/e2e.sh
new file mode 100755
--- /dev/null
+++ b/synth/testing_harness/errors/e2e.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+
+set -uo pipefail
+
+# load env vars
+if [ -f .env ]
+then
+  set -a
+  . .env
+  set +a
+fi
+
+SYNTH="synth"
+[ "${CI-false}" == "true" ] || { SYNTH="../../../target/debug/synth"; cargo build --bin synth || exit 1; }
+
+ERROR='\033[0;31m'
+INFO='\033[0;36m'
+DEBUG='\033[0;37m'
+NC='\033[0m' # No Color
+
+function help() {
+  echo "$1 <command>
+
+commands:
+  [test-name]|Optional parameter to run a specific test
+  --cleanup|Cleanup local files after a test run
+  --help|Shows this help text
+" | column -t -L -s"|"
+}
+
+function test() {
+
+  result=0
+  for ns in generate/*
+  do
+    test-specific $ns || { result=$?; echo; }
+  done
+
+  cleanup
+
+  echo -e "${DEBUG}Done${NC}"
+
+  return $result
+}
+
+function test-specific() {
+  echo -e "${INFO}Testing $1${NC}"
+  MSG=`$SYNTH generate $1 2>&1`
+  [ $? == 1 ] || { echo "$MSG"; echo -e "${ERROR}Expected error but got none${NC}"; return 1; }
+  diff --ignore-matching-lines="\[.*\]" <(echo "$MSG") "$1/errors.txt" || { echo "$MSG" | grep "\[.*\]"; echo -e "${ERROR}Errors do not match${NC}"; return 1; }
+}
+
+function cleanup() {
+  echo -e "${DEBUG}Cleaning up local files${NC}"
+  rm -Rf hospital_import
+  rm -Rf .synth
+}
+
+case "${1-all}" in
+  all)
+    test || exit 1
+    ;;
+  --cleanup)
+    cleanup
+    ;;
+  --help)
+    help $0
+    exit 1
+    ;;
+  *)
+    test-specific "generate/$1" || exit 1
+esac
diff --git a/synth/testing_harness/errors/generate/186-negative-array/errors.txt b/synth/testing_harness/errors/generate/186-negative-array/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/186-negative-array/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/186-negative-array"
+
+Caused by:
+    0: at file generate/186-negative-array/schema.json
+    1: Failed to parse collection
+    2: cannot have a negative array length at line 7 column 1
diff --git a/synth/testing_harness/errors/generate/186-negative-array/schema.json b/synth/testing_harness/errors/generate/186-negative-array/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/186-negative-array/schema.json
@@ -0,0 +1,8 @@
+{
+    "type": "array",
+    "length": -1,
+    "content": {
+        "type": "object"
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/190-faker-mistyped/errors.txt b/synth/testing_harness/errors/generate/190-faker-mistyped/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/190-faker-mistyped/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/190-faker-mistyped"
+
+Caused by:
+    0: at file generate/190-faker-mistyped/schema.json
+    1: Failed to parse collection
+    2: `faker` is expected to have a `generator` field. Try '"faker": {"generator": "name"}' at line 11 column 1
diff --git a/synth/testing_harness/errors/generate/190-faker-mistyped/schema.json b/synth/testing_harness/errors/generate/190-faker-mistyped/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/190-faker-mistyped/schema.json
@@ -0,0 +1,12 @@
+{
+    "type": "array",
+    "length": 1,
+    "content": {
+        "type": "object",
+        "s": {
+          "type": "string",
+          "faker": "name"
+        }
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/191-format-structure-wrong/errors.txt b/synth/testing_harness/errors/generate/191-format-structure-wrong/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/191-format-structure-wrong/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/191-format-structure-wrong"
+
+Caused by:
+    0: at file generate/191-format-structure-wrong/schema.json
+    1: Failed to parse collection
+    2: `arguments` is expected to be a field of `format` at line 17 column 1
diff --git a/synth/testing_harness/errors/generate/191-format-structure-wrong/schema.json b/synth/testing_harness/errors/generate/191-format-structure-wrong/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/191-format-structure-wrong/schema.json
@@ -0,0 +1,18 @@
+{
+    "type": "array",
+    "length": 1,
+    "content": {
+        "type": "object",
+        "s": {
+          "type": "string",
+          "format": "Hello {name}",
+          "arguments": {
+            "name": {
+              "type": "string",
+              "pattern": "synth"
+            }
+          }
+        }
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/192-string-missing-subtype/errors.txt b/synth/testing_harness/errors/generate/192-string-missing-subtype/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/192-string-missing-subtype/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/192-string-missing-subtype"
+
+Caused by:
+    0: at file generate/192-string-missing-subtype/schema.json
+    1: Failed to parse collection
+    2: `string` generator is missing a subtype. Try adding `pattern`, `faker`, `categorical`, `serialized`, `uuid`, `truncated`, or `format` at line 10 column 1
diff --git a/synth/testing_harness/errors/generate/192-string-missing-subtype/schema.json b/synth/testing_harness/errors/generate/192-string-missing-subtype/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/192-string-missing-subtype/schema.json
@@ -0,0 +1,11 @@
+{
+    "type": "array",
+    "length": 1,
+    "content": {
+        "type": "object",
+        "s": {
+            "type": "string"
+        }
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/193-array-length-missing-high/errors.txt b/synth/testing_harness/errors/generate/193-array-length-missing-high/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/193-array-length-missing-high/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/193-array-length-missing-high"
+
+Caused by:
+    0: at file generate/193-array-length-missing-high/schema.json
+    1: Failed to parse collection
+    2: missing high value for array length range at line 10 column 1
diff --git a/synth/testing_harness/errors/generate/193-array-length-missing-high/schema.json b/synth/testing_harness/errors/generate/193-array-length-missing-high/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/193-array-length-missing-high/schema.json
@@ -0,0 +1,11 @@
+{
+    "type": "array",
+    "length": {
+        "type": "number",
+        "subtype": "u32"
+    },
+    "content": {
+        "type": "object"
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/195-empty-array-length/errors.txt b/synth/testing_harness/errors/generate/195-empty-array-length/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/195-empty-array-length/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/195-empty-array-length"
+
+Caused by:
+    0: at file generate/195-empty-array-length/schema.json
+    1: Failed to parse collection
+    2: array length is not a constant or number type. Try replacing the '"length": {}' with '"length": [number]' where '[number]' is a positive integer at line 7 column 1
diff --git a/synth/testing_harness/errors/generate/195-empty-array-length/schema.json b/synth/testing_harness/errors/generate/195-empty-array-length/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/195-empty-array-length/schema.json
@@ -0,0 +1,8 @@
+{
+    "type": "array",
+    "length": {},
+    "content": {
+        "type": "object"
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/array-empty-content/errors.txt b/synth/testing_harness/errors/generate/array-empty-content/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/array-empty-content/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/array-empty-content"
+
+Caused by:
+    0: at file generate/array-empty-content/schema.json
+    1: Failed to parse collection
+    2: array content is missing. Try replacing the '"content": {}' with '"content": { "type": "object" }' at line 6 column 1
diff --git a/synth/testing_harness/errors/generate/array-empty-content/schema.json b/synth/testing_harness/errors/generate/array-empty-content/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/array-empty-content/schema.json
@@ -0,0 +1,7 @@
+{
+    "type": "array",
+    "length": 1,
+    "content": {
+    }
+}
+
diff --git a/synth/testing_harness/errors/generate/missing-type/errors.txt b/synth/testing_harness/errors/generate/missing-type/errors.txt
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/missing-type/errors.txt
@@ -0,0 +1,6 @@
+Error: Unable to open the namespace "generate/missing-type"
+
+Caused by:
+    0: at file generate/missing-type/schema.json
+    1: Failed to parse collection
+    2: missing field `type` at line 6 column 1
diff --git a/synth/testing_harness/errors/generate/missing-type/schema.json b/synth/testing_harness/errors/generate/missing-type/schema.json
new file mode 100644
--- /dev/null
+++ b/synth/testing_harness/errors/generate/missing-type/schema.json
@@ -0,0 +1,7 @@
+{
+    "length": 1,
+    "content": {
+        "type": "object"
+    }
+}
+