Repository: dotenv-linter/dotenv-linter
Pull request #761 (2024-03-05 00:22:29)

feat: new schema based checker
I do not expect this to be accepted as is. I am sure there will be feedback

There are a couple of big changed to the structure of the code

First I have unified the 3 different Options structs (Cli, Fix, Compare) into one CliOptions. Why, becuase its seems useful to pass the options to the checkers, rather than pull out one field (skips). But fix accepts options but calls check and fix, so either I had to synthesize a checkoptions from the fixoptions, or just make it one options type.

Next, I have added a call to the Check trait that allows them to be called after all lines have been processed. Useful for detecting missing things. Check::run loops over the checker at the end calling this new entry point.

Features
- specify what keys are allowed
- specify the type, String, Number, Url, Email, Boolean
- specify required or not
- specify regex for string
- specify if other keys other than those listed are allowed.

sample schema

```
{
    "version": "1.0.0",
    "allow_other_keys": true,
    "entries": [
        {
            "key": "SECRET",
            "required": true,
            "type": "String",
            "regex": "^[a-z]*$"
        },
        {
            "key": "PORT",
            "atype": "Number"
        },
        {
            "key": "URL",
            "type": "Url"
        },
        {
            "key": "EMAIL",
            "type": "Email"
        }
    ]
}
```

to use include -S | --schema on command line

if schema not found or is invalid then error message is printed and app exits

Not done yet
- allowed set of values for a string or number
- allowed range for numeric

Full set of tests included


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) Report
Attention: Patch coverage is `93.70370%` with `17 lines` in your changes are missing coverage. Please review.
> Project coverage is 98.66%. Comparing base [(`5b6de3a`)](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/commit/5b6de3a5e3c0b694fd5a3a504088d97f5ebd0108?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) to head [(`7316891`)](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter).
> Report is 2 commits behind head on master.

| [Files](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) | Patch % | Lines |
|---|---|---|
| [dotenv-linter/src/checks/schema\_validator.rs](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter#diff-ZG90ZW52LWxpbnRlci9zcmMvY2hlY2tzL3NjaGVtYV92YWxpZGF0b3IucnM=) | 90.32% | [6 Missing :warning: ](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) |
| [dotenv-linter/src/schema.rs](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter#diff-ZG90ZW52LWxpbnRlci9zcmMvc2NoZW1hLnJz) | 95.62% | [6 Missing :warning: ](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) |
| [dotenv-linter/src/cli/options.rs](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter#diff-ZG90ZW52LWxpbnRlci9zcmMvY2xpL29wdGlvbnMucnM=) | 72.22% | [5 Missing :warning: ](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #761      +/-   ##
==========================================
- Coverage   99.15%   98.66%   -0.49%     
==========================================
  Files          46       48       +2     
  Lines        2834     3068     +234     
==========================================
+ Hits         2810     3027     +217     
- Misses         24       41      +17     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/dotenv-linter/dotenv-linter/pull/761?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dotenv-linter).

pm100: i dont understand why the code coverage is saying I am missing a test for invlaid key, the test is there, gets run and passes.
pm100: i have fixed the missing coverages, but the coverage ci test needs to be rerun
DDtKey: Another idea in my mind in continue to this one, is to allow deriving of the schema from `.env` files.

To provide a way for users to generate a schema from existing files - they can be large. And then it can be edited manually. 

**This is definitely not for this PR**. 
But highlights that it makes much more sense to extract schema logic to sub crate. It can be used even outside of `dotenv-linter` 
pm100: I have one failing test because the text returned by failure to open a file is different on windows (My main dev system) and linux. I will try to fix,

You code coverage tool is a hard taskmaster - but very good at making sure everything is tested.
pm100: I also messed up the fix for that missing file test, hopefully I have it correct now
DDtKey: I apologize for such number of comments and suggestions. 
This is just a really great feature, but implementation is kinda tricky part here, it should be extensible.

Many of items can be done in iterative way, in separate PRs. Thus, we just need to collect features which we gonna schedule for the future enhancement. It also would allow other folks to contribute 🙂 

Thanks for all efforts addressing everything! 🙏 
pm100: @DDtKey are you waiting for me to do something. I think I have done all that you asked, I have all checks passing and 100% coverage
DDtKey: Actually I'm not the only maintainer, so need @dotenv-linter/core to review as well (only @mgrachev can merge / make final decision here).

But from my side, I still think it would be nice [this part](https://github.com/dotenv-linter/dotenv-linter/pull/761#discussion_r1518654043) to be implemented 🤔 
I believe it can be done on deserialization level (with [internally tagged enum](https://serde.rs/enum-representations.html#internally-tagged) where tag is type), this way we will have some basic validations of the schema structure even before applying any logic. 
pm100: That works if we are deseriazling enums directly - we are not doing that. So it has to be done manually (or at least I cant work out how to do it) - which is very simple. I can change the enum to 
```
pub enum SchemaValueType {
    #[default]
    String(Option<String>), 
    Integer,
    Float,
    Boolean,
    Url,
    Email,
}
```

if you like

I can reject a regex on anything other than string too.

Do you want that?
pm100: I dont think serde can do it when we are not deserializing the enums directly. I can make serde do it directly from a different json syntax that I dont like so much

```
[ 
  "key":"NAME",
   "type":{"String" :null}
]
```

or 

```
[ 
  "key":"NAME",
   "type":{"String" :"regex"}
]

```
which is very odd.

I can change the enum to 

```
pub enum SchemaValueType {
    // #[default]
    String(Option<String>),
    Integer,
    Float,
    #[default]
    Boolean,
    Url,
    Email,
}
```

if you like ( I dont think it adds anything)

I will have to remap it manually - which is fine, just like the vec to hashmap translation

I will then reject a regex on anything other than a string. Or I can simply do that without changing the enum
DDtKey: That's possible, did you consider to use `flatten`?
Here is you can find working example on rust's playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=a272538dd8572b664bc4494804cdd3a5
mgrachev: @pm100 Thank you for your contribution 👍

Sorry, but I need more time to review your PR, so I'll look at it around the weekend.
pm100: All done except CliOptions - see my comment
pm100: reverted all Cli changes

using serde_regex
pm100: Why not accept the PR, which is fully featured and passes 100% test coverage - as far as I all the proposed changes are stylistic choices - then change it however you like. There are tons of extra features that can be added once the base support is in:
- use the schema for fix and compare
- add numeric ranges
- add min and max lengths
- add allowed values
- add support for dir and file type , both syntactically valid and optionally check for existence
- improve booleans (localizable???)

Or close it
DDtKey: I think there is no point in merging the PR with an unfinished schema. This is the most important part of PR.
And if we accept it unfinished, then we need someone to work on it to get the job done. 

We are not asking for additional features, just changes to an important part. I don't think merging the intermediate state of a feature is a good idea.

Even if we did, we wouldn't be able to release it until we were ready to introduce the schema-format without making incompatible changes immediately afterwards.

But you have already done a huge amount of work (thank you!) and are familiar with how this can be changed
pm100: I do not know what changes to the schema are being asked, I only see implementation changes. Can somebody please post the proposed schema syntax and I will implement it
DDtKey: Sure, the suggestion was in the thread above https://github.com/dotenv-linter/dotenv-linter/pull/761#discussion_r1531048360
pm100: the problem with that schema is that it is not describable in a json schema itself, so json aware editors wont like it.

The only reason for the proposed change is to slightly simplify the implementation on reading the schema. Which isnt a compelling reason IMHO
DDtKey: > the problem with that schema is that it is not describable in a json schema itself, so json aware editors wont like it.

Didn't really get your point. It's valid JSON and I any JSON editor works with this.

> The only reason for the proposed change is to slightly simplify the implementation on reading the schema. Which isnt a compelling reason IMHO

Well, I see at least couple of reasons:
- any JSON linter/validator will highlight duplicative keys. We won't have to deal with this explicitly
- It has a good representation in yaml (we may want to support yaml or toml in the future), for example above:

```yaml
---
---
version: '1.0'
allow_other_keys: false
entries:
  SECRET:
    required: true
    type: String
    regex: "^[a-z]*$"
  PORT:
    type: Integer
  URL:
    type: Url

```
pm100: ok, I can change to that format. But its much harder to detect duplicated keys - which I have to do and forgot in the original code.

The problem is that serde happily accepts duplicates. Ie this

```
{
    "version": "1.0.0",
    "allow_other_keys": false,
    "entries": {
        "SECRET": {
            "required": true,
            "type": "String",
            "regex": "^[a-z]*$"
        },
        "PORT": {
            "type": "String"
        },
        "PORT": {
            "type": "Integer"
        }
    }
}
```
It means that I have to deserialize some other way

see https://stackoverflow.com/questions/64405600/how-can-i-deserialize-json-that-has-duplicate-keys-without-losing-any-of-the-val

And since the only real purpose of this change was to make the deserialze easier I dont really like it.

BTW - it is possible tp describe this format using json schema - it just wasnt obvious
DDtKey: > The problem is that serde happily accepts duplicates.

Most common solution is to use `serde_with` for this:
https://docs.rs/serde_with/latest/serde_with/rust/maps_duplicate_key_is_error/index.html

That's why I mentioned that we can get duplicate-check for free.

> And since the only real purpose of this change was to make the deserialze easier I dont really like it.

Please don't claim that this is the only reason. I suggested this earlier and above you see at least two more reasons
pm100: >That's why I mentioned that we can get duplicate-check for free.

we get duplicate check for free in some editors, not at run time. So with this schema json dotenv-linter will accept schemas with duplicated keys, which is not what you want (I assume)
DDtKey: > we get duplicate check for free in some editors, not at run time. So with this schema json dotenv-linter will accept schemas with duplicated keys, which is not what you want (I assume

Sorry, but did you read what I suggested? (don't consider it rude) 
There is a solution to disallow duplicative keys on deserialization step with `serde_with`
pm100: done

Patch:
diff --git a/dotenv-linter/Cargo.toml b/dotenv-linter/Cargo.toml
index 74b71d8..bab712e 100644
--- a/dotenv-linter/Cargo.toml
+++ b/dotenv-linter/Cargo.toml
@@ -16,6 +16,13 @@ description = "Lightning-fast linter for .env files"
 clap = { version = "4.5.3", features = ["cargo", "env"] }
 colored = "2.1.0"
 dotenv-lookup = { version = "1.0.0", path = "../dotenv-lookup" }
+email_address = "0.2.4"
+regex = "1.10.3"
+serde = "1.0.197"
+serde_json = "1.0.114"
+serde_regex = "1.1.0"
+serde_with = "3.7.0"
+url = "2.5.0"
 
 [dependencies.update-informer]
 version = "1.1.0"
diff --git a/dotenv-linter/benches/check.rs b/dotenv-linter/benches/check.rs
index e092ea6..998343c 100644
--- a/dotenv-linter/benches/check.rs
+++ b/dotenv-linter/benches/check.rs
@@ -12,7 +12,7 @@ pub fn check_benchmark(c: &mut Criterion) {
     let current_dir = env::current_dir().expect("get current dir");
     let app = dotenv_linter::cli::command();
     let args = app.get_matches_from(vec!["dotenv-linter", path.to_str().expect("path to str")]);
-    let opts = dotenv_linter::cli::options::CheckOptions::new(&args);
+    let opts = dotenv_linter::cli::options::CliOptions::new_check(&args);
 
     fs::copy("benches/fixtures/simple.env", path.join(".env")).expect("copy .env file");
 
diff --git a/dotenv-linter/benches/compare.rs b/dotenv-linter/benches/compare.rs
index 7b1c816..3b74b4e 100644
--- a/dotenv-linter/benches/compare.rs
+++ b/dotenv-linter/benches/compare.rs
@@ -19,7 +19,7 @@ pub fn compare_benchmark(c: &mut Criterion) {
             .to_str()
             .expect(".env.compare to str"),
     ]);
-    let opts = dotenv_linter::cli::options::CompareOptions::new(&args);
+    let opts = dotenv_linter::cli::options::CliOptions::new_compare(&args);
 
     fs::copy("benches/fixtures/simple.env", path.join(".env")).expect("copy .env file");
     fs::copy("benches/fixtures/compare.env", path.join(".env.compare"))
diff --git a/dotenv-linter/benches/fix.rs b/dotenv-linter/benches/fix.rs
index d015c3a..a784074 100644
--- a/dotenv-linter/benches/fix.rs
+++ b/dotenv-linter/benches/fix.rs
@@ -41,7 +41,7 @@ pub fn fix_benchmark(c: &mut Criterion) {
         b.iter_batched(
             || generate_arg_matches(&app, false),
             |args| {
-                let opts = dotenv_linter::cli::options::FixOptions::new(&args);
+                let opts = dotenv_linter::cli::options::CliOptions::new_fix(&args);
                 dotenv_linter::fix(black_box(&opts), black_box(&current_dir))
             },
             BatchSize::SmallInput,
@@ -60,7 +60,7 @@ pub fn fix_benchmark_with_backup(c: &mut Criterion) {
         b.iter_batched(
             || generate_arg_matches(&app, true),
             |args| {
-                let opts = dotenv_linter::cli::options::FixOptions::new(&args);
+                let opts = dotenv_linter::cli::options::CliOptions::new_fix(&args);
                 dotenv_linter::fix(black_box(&opts), black_box(&current_dir))
             },
             BatchSize::SmallInput,
diff --git a/dotenv-linter/src/checks.rs b/dotenv-linter/src/checks.rs
index 836a211..8b01ef9 100644
--- a/dotenv-linter/src/checks.rs
+++ b/dotenv-linter/src/checks.rs
@@ -1,5 +1,6 @@
 use crate::common::comment::Comment;
 use crate::common::{LintKind, Warning};
+use crate::schema::DotEnvSchema;
 use dotenv_lookup::LineEntry;
 
 mod duplicated_key;
@@ -10,6 +11,7 @@
 mod leading_character;
 mod lowercase_key;
 mod quote_character;
+mod schema_violation;
 mod space_character;
 mod substitution_key;
 mod trailing_whitespace;
@@ -23,10 +25,13 @@ pub trait Check {
     fn skip_comments(&self) -> bool {
         true
     }
+    fn end(&mut self) -> Vec<Warning> {
+        vec![]
+    }
 }
 
 // Checklist for checks which needs to know of only a single line
-fn checklist() -> Vec<Box<dyn Check>> {
+fn checklist<'a>(schema: Option<&'a DotEnvSchema>) -> Vec<Box<dyn Check + 'a>> {
     vec![
         Box::<duplicated_key::DuplicatedKeyChecker>::default(),
         Box::<ending_blank_line::EndingBlankLineChecker>::default(),
@@ -41,11 +46,16 @@ fn checklist() -> Vec<Box<dyn Check>> {
         Box::<trailing_whitespace::TrailingWhitespaceChecker>::default(),
         Box::<unordered_key::UnorderedKeyChecker>::default(),
         Box::<value_without_quotes::ValueWithoutQuotesChecker>::default(),
+        Box::new(schema_violation::SchemaViolationChecker::new(schema)),
     ]
 }
 
-pub fn run(lines: &[LineEntry], skip_checks: &[LintKind]) -> Vec<Warning> {
-    let mut checks = checklist();
+pub fn run(
+    lines: &[LineEntry],
+    skip_checks: &[LintKind],
+    schema: Option<&DotEnvSchema>,
+) -> Vec<Warning> {
+    let mut checks = checklist(schema);
 
     // Skip checks with the --skip argument (globally)
     checks.retain(|c| !skip_checks.contains(&c.name()));
@@ -81,6 +91,11 @@ pub fn run(lines: &[LineEntry], skip_checks: &[LintKind]) -> Vec<Warning> {
         }
     }
 
+    for ch in &mut checks {
+        let end_warns = ch.end();
+        warnings.extend(end_warns);
+    }
+
     warnings
 }
 
@@ -94,8 +109,7 @@ fn run_with_empty_vec_test() {
         let empty: Vec<LineEntry> = Vec::new();
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&empty, &skip_checks));
+        assert_eq!(expected, run(&empty, &skip_checks, None));
     }
 
     #[test]
@@ -103,8 +117,7 @@ fn run_with_empty_line_test() {
         let lines: Vec<LineEntry> = vec![blank_line_entry(1, 1)];
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -115,8 +128,7 @@ fn run_with_comment_line_test() {
         ];
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -124,8 +136,7 @@ fn run_with_valid_line_test() {
         let lines: Vec<LineEntry> = vec![line_entry(1, 2, "FOO=BAR"), blank_line_entry(2, 2)];
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -139,8 +150,7 @@ fn run_with_invalid_line_test() {
         let lines: Vec<LineEntry> = vec![line, blank_line_entry(2, 2)];
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -154,8 +164,7 @@ fn run_without_blank_line_test() {
         let lines: Vec<LineEntry> = vec![line];
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<LintKind> = Vec::new();
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -169,9 +178,9 @@ fn skip_one_check() {
         );
         let lines: Vec<LineEntry> = vec![line1, line2, blank_line_entry(3, 3)];
         let expected: Vec<Warning> = vec![warning];
-        let skip_checks: Vec<LintKind> = vec![LintKind::KeyWithoutValue, LintKind::UnorderedKey];
+        let skip_checks = vec![LintKind::KeyWithoutValue, LintKind::UnorderedKey];
 
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -179,9 +188,9 @@ fn skip_all_checks() {
         let line = line_entry(1, 1, "FOO");
         let lines: Vec<LineEntry> = vec![line];
         let expected: Vec<Warning> = Vec::new();
-        let skip_checks: Vec<LintKind> = vec![LintKind::KeyWithoutValue, LintKind::EndingBlankLine];
+        let skip_checks = vec![LintKind::KeyWithoutValue, LintKind::EndingBlankLine];
 
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -196,9 +205,9 @@ fn skip_one_check_via_comment() {
         );
         let lines: Vec<LineEntry> = vec![line1, line2, line3, blank_line_entry(4, 4)];
         let expected: Vec<Warning> = vec![warning];
-        let skip_checks: Vec<LintKind> = vec![LintKind::UnorderedKey];
+        let skip_checks = vec![LintKind::UnorderedKey];
 
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -213,9 +222,8 @@ fn skip_collision() {
         );
         let lines: Vec<LineEntry> = vec![line1, line2, line3, blank_line_entry(4, 4)];
         let expected: Vec<Warning> = vec![warning];
-        let skip_checks: Vec<LintKind> = vec![LintKind::KeyWithoutValue, LintKind::UnorderedKey];
-
-        assert_eq!(expected, run(&lines, &skip_checks));
+        let skip_checks = vec![LintKind::KeyWithoutValue, LintKind::UnorderedKey];
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -237,7 +245,7 @@ fn on_and_off_same_checks() {
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<LintKind> = Vec::new();
 
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -252,7 +260,7 @@ fn only_simple_comment() {
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<LintKind> = Vec::new();
 
-        assert_eq!(expected, run(&lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks, None));
     }
 
     #[test]
@@ -270,6 +278,6 @@ fn unordered_key_with_control_comment_test() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<LintKind> = Vec::new();
 
-        assert_eq!(expected, run(&line_entries, &skip_checks));
+        assert_eq!(expected, run(&line_entries, &skip_checks, None));
     }
 }
diff --git a/dotenv-linter/src/checks/schema_violation.rs b/dotenv-linter/src/checks/schema_violation.rs
new file mode 100644
index 0000000..d45f95b
--- /dev/null
+++ b/dotenv-linter/src/checks/schema_violation.rs
@@ -0,0 +1,139 @@
+use std::collections::HashSet;
+
+use super::Check;
+use crate::{
+    common::{LintKind, Warning},
+    schema::{DotEnvSchema, SchemaValueType},
+};
+use dotenv_lookup::LineEntry;
+use email_address::EmailAddress;
+use url::Url;
+pub(crate) struct SchemaViolationChecker<'a> {
+    schema: Option<&'a crate::schema::DotEnvSchema>,
+    seen_keys: HashSet<String>,
+    last_line_number: usize,
+}
+
+impl<'a> SchemaViolationChecker<'a> {
+    pub fn new(schema: Option<&'a DotEnvSchema>) -> Self {
+        Self {
+            schema: schema,
+            seen_keys: HashSet::new(),
+            last_line_number: 0,
+        }
+    }
+}
+
+impl Check for SchemaViolationChecker<'_> {
+    fn run(&mut self, line: &LineEntry) -> Option<Warning> {
+        let schema = self.schema?;
+        self.last_line_number = line.number;
+        let key = line.get_key()?;
+        self.seen_keys.insert(key.to_string());
+
+        if let Some(entry) = schema.entries.get(key) {
+            if let Some(value) = line.get_value() {
+                match entry.value_type {
+                    SchemaValueType::String => {
+                        if let Some(regex) = &entry.regex {
+                            if !regex.is_match(value) {
+                                return Some(Warning::new(
+                                    line.number,
+                                    self.name(),
+                                    format!("The {} key does not match the regex", key),
+                                ));
+                            }
+                        }
+                    }
+                    SchemaValueType::Boolean => {
+                        if matches!(
+                            value,
+                            "true"
+                                | "false"
+                                | "TRUE"
+                                | "FALSE"
+                                | "yes"
+                                | "no"
+                                | "YES"
+                                | "NO"
+                                | "1"
+                                | "0"
+                        ) {
+                            return None;
+                        } else {
+                            return Some(Warning::new(
+                                line.number,
+                                self.name(),
+                                format!("The {} key is not a valid boolean", key),
+                            ));
+                        }
+                    }
+                    SchemaValueType::Integer => {
+                        if value.parse::<i32>().is_err() {
+                            return Some(Warning::new(
+                                line.number,
+                                self.name(),
+                                format!("The {} key is not an integer", key),
+                            ));
+                        }
+                    }
+                    SchemaValueType::Float => {
+                        if value.parse::<f32>().is_err() {
+                            return Some(Warning::new(
+                                line.number,
+                                self.name(),
+                                format!("The {} key is not a valid float", key),
+                            ));
+                        }
+                    }
+                    SchemaValueType::Email => {
+                        if !EmailAddress::is_valid(value) {
+                            return Some(Warning::new(
+                                line.number,
+                                self.name(),
+                                format!("The {} key is not a valid email address", key),
+                            ));
+                        }
+                    }
+                    SchemaValueType::Url => {
+                        if Url::parse(value).is_err() {
+                            return Some(Warning::new(
+                                line.number,
+                                self.name(),
+                                format!("The {} key is not a valid URL", key),
+                            ));
+                        }
+                    }
+                }
+            }
+        } else if !schema.allow_other_keys {
+            return Some(Warning::new(
+                line.number,
+                self.name(),
+                format!("The {} key is not defined in the schema", key),
+            ));
+        }
+
+        None
+    }
+
+    fn name(&self) -> LintKind {
+        LintKind::SchemaViolation
+    }
+
+    fn end(&mut self) -> Vec<Warning> {
+        let mut warnings = Vec::new();
+        if let Some(schema) = self.schema {
+            for (key, entry) in &schema.entries {
+                if entry.required && !self.seen_keys.contains(key) {
+                    warnings.push(Warning::new(
+                        self.last_line_number,
+                        self.name(),
+                        format!("The {} key is required", key),
+                    ));
+                }
+            }
+        }
+        warnings
+    }
+}
diff --git a/dotenv-linter/src/cli.rs b/dotenv-linter/src/cli.rs
index 4e0ac66..2afb50c 100644
--- a/dotenv-linter/src/cli.rs
+++ b/dotenv-linter/src/cli.rs
@@ -1,11 +1,9 @@
-use crate::{
-    cli::options::{CheckOptions, CompareOptions, FixOptions},
-    common::LintKind,
-    Result,
-};
+use crate::{common::LintKind, Result};
 use clap::{command, value_parser, Arg, ArgAction, Command};
 use std::path::PathBuf;
 
+use self::options::{CheckOptions, CompareOptions, FixOptions};
+
 pub mod options;
 
 pub fn run() -> Result<i32> {
@@ -130,6 +128,11 @@ fn common_args() -> Vec<Arg> {
             .long("recursive")
             .help("Recursively searches and checks .env files")
             .action(ArgAction::SetTrue),
+        Arg::new("schema")
+            .short('S')
+            .long("schema")
+            .help("Use schema file to check .env files")
+            .value_parser(value_parser!(PathBuf)),
         no_color_flag(),
         quiet_flag(),
     ]
diff --git a/dotenv-linter/src/cli/options.rs b/dotenv-linter/src/cli/options.rs
index 948c95d..32714cd 100644
--- a/dotenv-linter/src/cli/options.rs
+++ b/dotenv-linter/src/cli/options.rs
@@ -1,4 +1,7 @@
-use crate::common::LintKind;
+use crate::{
+    common::LintKind,
+    schema::{self, DotEnvSchema},
+};
 use clap::ArgMatches;
 use std::path::PathBuf;
 
@@ -8,6 +11,7 @@ pub struct CheckOptions<'a> {
     pub exclude: Vec<&'a PathBuf>,
     pub quiet: bool,
     pub recursive: bool,
+    pub schema: Option<DotEnvSchema>,
 }
 
 pub struct FixOptions<'a> {
@@ -30,13 +34,24 @@ pub fn new(args: &'a ArgMatches) -> Self {
             .into_iter()
             .map(|l| l.to_owned())
             .collect();
-
+        let schema = if let Some(schema_path) = args.get_one::<PathBuf>("schema") {
+            match schema::DotEnvSchema::load(schema_path) {
+                Ok(schema) => Some(schema),
+                Err(err) => {
+                    println!("Error loading schema: {}", err);
+                    std::process::exit(1);
+                }
+            }
+        } else {
+            None
+        };
         Self {
             skip,
             input: get_many_from_args::<PathBuf>(args, "input"),
             exclude: get_many_from_args::<PathBuf>(args, "exclude"),
             quiet: args.get_flag("quiet"),
             recursive: args.get_flag("recursive"),
+            schema,
         }
     }
 }
diff --git a/dotenv-linter/src/common/lint_kind.rs b/dotenv-linter/src/common/lint_kind.rs
index 423d897..b6c93da 100644
--- a/dotenv-linter/src/common/lint_kind.rs
+++ b/dotenv-linter/src/common/lint_kind.rs
@@ -17,6 +17,7 @@ pub enum LintKind {
     TrailingWhitespace,
     UnorderedKey,
     ValueWithoutQuotes,
+    SchemaViolation,
 }
 
 impl ValueEnum for LintKind {
@@ -35,6 +36,7 @@ fn value_variants<'a>() -> &'a [Self] {
             LintKind::TrailingWhitespace,
             LintKind::UnorderedKey,
             LintKind::ValueWithoutQuotes,
+            LintKind::SchemaViolation,
         ]
     }
 
@@ -53,6 +55,7 @@ fn to_possible_value<'a>(&self) -> Option<PossibleValue> {
             LintKind::TrailingWhitespace => PossibleValue::new("TrailingWhitespace"),
             LintKind::UnorderedKey => PossibleValue::new("UnorderedKey"),
             LintKind::ValueWithoutQuotes => PossibleValue::new("ValueWithoutQuotes"),
+            LintKind::SchemaViolation => PossibleValue::new("SchemaViolation"),
         })
     }
 }
diff --git a/dotenv-linter/src/lib.rs b/dotenv-linter/src/lib.rs
index a071384..f6954b4 100644
--- a/dotenv-linter/src/lib.rs
+++ b/dotenv-linter/src/lib.rs
@@ -8,6 +8,7 @@
 mod common;
 mod fixes;
 mod fs_utils;
+mod schema;
 
 pub mod cli;
 
@@ -33,7 +34,7 @@ pub fn check(opts: &CheckOptions, current_dir: &PathBuf) -> Result<usize> {
         .enumerate()
         .fold(0, |acc, (index, (fe, lines))| {
             output.print_processing_info(&fe);
-            let result = checks::run(&lines, &opts.skip);
+            let result = checks::run(&lines, &opts.skip, opts.schema.as_ref());
 
             output.print_warnings(&fe, &result, index);
             acc + result.len()
@@ -62,8 +63,7 @@ pub fn fix(opts: &FixOptions, current_dir: &PathBuf) -> Result<()> {
     for (index, (fe, mut lines)) in files.into_iter().enumerate() {
         output.print_processing_info(&fe);
 
-        // let mut lines = get_line_entries(strings);
-        let result = checks::run(&lines, &opts.skip);
+        let result = checks::run(&lines, &opts.skip, None);
         if result.is_empty() {
             continue;
         }
diff --git a/dotenv-linter/src/schema.rs b/dotenv-linter/src/schema.rs
new file mode 100644
index 0000000..3f99c75
--- /dev/null
+++ b/dotenv-linter/src/schema.rs
@@ -0,0 +1,492 @@
+use regex::Regex;
+use serde::Deserialize;
+use std::{collections::HashMap, fs::File, io::BufReader, path::Path};
+
+#[derive(Deserialize, Default, Debug)]
+#[serde(default)]
+pub struct SchemaEntry {
+    pub key: String,
+    pub required: bool,
+    #[serde(rename = "type")]
+    pub value_type: SchemaValueType,
+    #[serde(with = "serde_regex")]
+    pub regex: Option<Regex>,
+}
+#[derive(Deserialize, Default, Debug)]
+pub struct DotEnvSchema {
+    pub version: String,
+    #[serde(default)]
+    pub allow_other_keys: bool,
+    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
+    pub entries: HashMap<String, SchemaEntry>,
+}
+
+#[derive(Deserialize, Default, Debug)]
+pub enum SchemaValueType {
+    #[default]
+    String,
+    Integer,
+    Float,
+    Boolean,
+    Url,
+    Email,
+}
+
+impl DotEnvSchema {
+    pub fn load(path: &Path) -> Result<Self, std::io::Error> {
+        let file = File::open(path)?;
+        let reader = BufReader::new(file);
+        let read_schema: DotEnvSchema = serde_json::from_reader(reader)?;
+        Ok(read_schema)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use dotenv_lookup::LineEntry;
+    use regex::Regex;
+    use std::fs::{self, File};
+    use std::io::Write;
+    use tempfile::tempdir;
+
+    use super::DotEnvSchema;
+
+    use crate::common::tests::*;
+    use crate::{LintKind, Warning};
+
+    fn load_schema() -> Result<DotEnvSchema, std::io::Error> {
+        let json = r#"{
+            "version": "1.0.0",
+            "entries": {
+                "NAME": {               
+                    "type": "String"
+                },
+                "PORT": {
+                    "type": "Integer"
+                },
+                "PRICE": {
+                    "type": "Float"
+                },
+                "URL": {               
+                    "type": "Url"
+                },
+                "EMAIL":{
+                    "type": "Email"
+                },
+                "FLAG":{
+                    "type": "Boolean"
+                }
+            }
+        }"#;
+        let schema: DotEnvSchema = serde_json::from_str(json).unwrap();
+        Ok(schema)
+    }
+
+    #[test]
+    fn string_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "NAME=joe")];
+        let expected: Vec<Warning> = Vec::new();
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn string_unknown() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "USER=joe")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The USER key is not defined in the schema",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn string_unknown_allowed() {
+        let mut schema = load_schema().expect("failed to load schema");
+        schema.allow_other_keys = true;
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "USER=joe")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn integer_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PORT=42")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn integer_bad() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PORT=p")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The PORT key is not an integer",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn integer_is_float() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PORT=2.4")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The PORT key is not an integer",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn float_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PRICE=2.4")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn float_good2() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PRICE=24")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn float_bad() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "PRICE=price")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The PRICE key is not a valid float",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn url_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "URL=https://example.com")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn url_bad() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "URL=not_a_url")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The URL key is not a valid URL",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn email_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "EMAIL=joe@gmail.com")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn email_bad() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "EMAIL=not_an_eamil")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The EMAIL key is not a valid email address",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn required_present() {
+        let mut schema = load_schema().expect("failed to load schema");
+        schema.entries.get_mut("EMAIL").unwrap().required = true;
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "EMAIL=joe@gmail.com")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn required_missing() {
+        let mut schema = load_schema().expect("failed to load schema");
+        schema.entries.get_mut("EMAIL").unwrap().required = true;
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "NAME=joe")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The EMAIL key is required",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn regex_good() {
+        let mut schema = load_schema().expect("failed to load schema");
+        schema.entries.get_mut("NAME").unwrap().regex =
+            Some(Regex::new("^[ABCD]*$").expect("Bad regex"));
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "NAME=BAD")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn regex_bad() {
+        let mut schema = load_schema().expect("failed to load schema");
+        schema.entries.get_mut("NAME").unwrap().regex =
+            Some(Regex::new("^[ABCD]*$").expect("Bad regex"));
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "NAME=joe")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The NAME key does not match the regex",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn boolean_good() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "FLAG=true")];
+        let expected: Vec<Warning> = vec![];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn boolean_bad() {
+        let schema = load_schema().expect("failed to load schema");
+        let lines: Vec<LineEntry> = vec![line_entry(1, 2, "FLAG=joe")];
+        let expected: Vec<Warning> = vec![Warning::new(
+            1,
+            LintKind::SchemaViolation,
+            "The FLAG key is not a valid boolean",
+        )];
+        let skip_checks: Vec<LintKind> = Vec::new();
+        assert_eq!(
+            expected,
+            crate::checks::run(&lines, &skip_checks, Some(&schema))
+        );
+    }
+
+    #[test]
+    fn create_file_schema() {
+        let json = r#"{
+            "version": "1.0.0",
+            "entries": {
+                "NAME": {               
+                    "type": "String"
+                },
+                "PORT": {
+                    "type": "Integer"
+                },
+                "PRICE": {
+                    "type": "Float"
+                },
+                "URL": {               
+                    "type": "Url"
+                },
+                "EMAIL":{
+                    "type": "Email"
+                },
+                "FLAG":{
+                    "type": "Boolean"
+                }
+            }
+        }"#;
+        // write the above json to a temp file
+        let temp_dir = tempdir().unwrap();
+        let file_path = temp_dir.path().join("schema.json");
+        let schema = {
+            let mut file = File::create(&file_path).unwrap();
+            file.write_all(json.as_bytes()).unwrap();
+            // load the schema from the file
+            DotEnvSchema::load(&file_path)
+        };
+        fs::remove_file(&file_path).unwrap();
+        assert!(schema.is_ok());
+    }
+
+    #[test]
+    fn load_missing_file() {
+        assert!(DotEnvSchema::load(std::path::Path::new("bad_file.json")).is_err());
+    }
+
+    #[test]
+    fn create_bad_regex_file_schema() {
+        let json = r#"{
+            "version": "1.0.0",
+            "entries": {
+                "NAME": {               
+                    "type": "String",
+                    "regex": "~[.."
+                },
+                "PORT": {
+                    "type": "Integer"
+                },
+                "PRICE": {
+                    "type": "Float"
+                },
+                "URL": {               
+                    "type": "Url"
+                },
+                "EMAIL":{
+                    "type": "Email"
+                },
+                "FLAG":{
+                    "type": "Boolean"
+                }
+            }
+        }"#;
+        // write the above json to a temp file
+        let temp_dir = tempdir().unwrap();
+        let file_path = temp_dir.path().join("schema.json");
+        let schema = {
+            let mut file = File::create(&file_path).unwrap();
+            file.write_all(json.as_bytes()).unwrap();
+            // load the schema from the file
+            DotEnvSchema::load(&file_path)
+        };
+        fs::remove_file(&file_path).unwrap();
+        assert!(schema.is_err());
+    }
+
+    #[test]
+    fn create_bad_file_schema() {
+        let json = r#"{
+            "version": "1.0.0",
+            bad:json
+        }"#;
+        // write the above json to a temp file
+        let temp_dir = tempdir().unwrap();
+        let file_path = temp_dir.path().join("schema.json");
+        let schema = {
+            let mut file = File::create(&file_path).unwrap();
+            file.write_all(json.as_bytes()).unwrap();
+            // load the schema from the file
+            DotEnvSchema::load(&file_path)
+        };
+        fs::remove_file(&file_path).unwrap();
+        assert!(schema.is_err());
+    }
+    #[test]
+    fn test_dup_schema() {
+        let json = r#"{
+            "version": "1.0.0",
+            "entries": {
+                "NAME": {               
+                    "type": "String"
+                },
+                "NAME": {               
+                    "type": "String"
+                },
+                "PORT": {
+                    "type": "Integer"
+                },
+                "PRICE": {
+                    "type": "Float"
+                },
+                "URL": {               
+                    "type": "Url"
+                },
+                "EMAIL":{
+                    "type": "Email"
+                },
+                "FLAG":{
+                    "type": "Boolean"
+                }
+            }
+        }"#;
+        let schema: serde_json::Result<DotEnvSchema> = serde_json::from_str(json);
+        assert!(schema.is_err());
+        assert_eq!(
+            schema.unwrap_err().to_string(),
+            "invalid entry: found duplicate key at line 9 column 17"
+        );
+    }
+}


Test patch:
diff --git a/dotenv-linter/tests/checks/mod.rs b/dotenv-linter/tests/checks/mod.rs
index 4366f43..8b824df 100644
--- a/dotenv-linter/tests/checks/mod.rs
+++ b/dotenv-linter/tests/checks/mod.rs
@@ -6,6 +6,7 @@
 mod leading_character;
 mod lowercase_key;
 mod quote_character;
+mod schema;
 mod space_character;
 mod substitution_key;
 mod trailing_whitespace;
diff --git a/dotenv-linter/tests/checks/schema.rs b/dotenv-linter/tests/checks/schema.rs
new file mode 100644
index 0000000..f5e407c
--- /dev/null
+++ b/dotenv-linter/tests/checks/schema.rs
@@ -0,0 +1,85 @@
+use crate::common::*;
+use std::{
+    fs::{remove_file, File},
+    io::Write,
+};
+use tempfile::tempdir;
+
+#[test]
+fn load_good_schema() {
+    let json = r#"{
+        "version": "1.0.0",
+        "entries": {
+            "NAME": {               
+                "type": "String"
+            },
+            "PORT": {
+                "type": "Integer"
+            },
+            "PRICE": {
+                "type": "Float"
+            },
+            "URL": {               
+                "type": "Url"
+            },
+            "EMAIL":{
+                "type": "Email"
+            },
+            "FLAG":{
+                "type": "Boolean"
+            }
+        }
+    }"#;
+    // write the above json to a temp file
+    let temp_dir = tempdir().expect("create temp dir");
+    let file_path = temp_dir.path().join("schema.json");
+    {
+        let mut file = File::create(&file_path).unwrap();
+        file.write_all(json.as_bytes()).unwrap();
+    }
+    let content = "NAME=JOE\n";
+
+    let testdir = TestDir::new();
+    let testfile = testdir.create_testfile(".env", content);
+    let args = &["-S", file_path.to_str().unwrap(), testfile.as_str()];
+
+    let expected_output = check_output(&[(".env", &[])]);
+
+    testdir.test_command_success_with_args(with_default_args(args), expected_output);
+    let _ = remove_file(file_path);
+}
+
+#[test]
+fn load_bad_schema() {
+    let json = r#"{
+        "version": "1.0.0",
+        bad_json
+    }"#;
+    // write the above json to a temp file
+
+    let testdir = TestDir::new();
+    let test_schema = testdir.create_testfile("schema.json", json);
+
+    let content = "NAME=JOE\n";
+    let testfile = testdir.create_testfile(".env", content);
+    let args = &["-S", test_schema.as_str(), testfile.as_str()];
+
+    let expected_output = "Error loading schema: key must be a string at line 3 column 9\n";
+
+    testdir.test_command_fail_with_args(with_default_args(args), expected_output);
+}
+
+#[test]
+fn load_missing_schema() {
+    let content = "NAME=JOE\n";
+
+    let testdir = TestDir::new();
+    let testfile = testdir.create_testfile(".env", content);
+    let args = &["-S", "no_such_file", testfile.as_str()];
+    let expected_output = if cfg!(target_os = "windows") {
+        "Error loading schema: The system cannot find the file specified. (os error 2)\n"
+    } else {
+        "Error loading schema: No such file or directory (os error 2)\n"
+    };
+    testdir.test_command_fail_with_args(with_default_args(args), expected_output);
+}