dotenv_schema/
lib.rs

1use std::{collections::HashMap, fs::File, io::BufReader, path::Path};
2
3use regex::Regex;
4use serde::Deserialize;
5
6#[cfg(feature = "clap")]
7pub mod clap;
8
9#[derive(Deserialize, Default, Debug, Clone)]
10pub struct DotEnvSchema {
11    pub version: String,
12    #[serde(default)]
13    pub allow_other_keys: bool,
14    #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")]
15    pub entries: HashMap<String, SchemaEntry>,
16}
17
18#[derive(Deserialize, Default, Debug, Clone)]
19#[serde(default)]
20pub struct SchemaEntry {
21    pub key: String,
22    pub required: bool,
23    #[serde(rename = "type")]
24    pub value_type: SchemaValueType,
25    #[serde(with = "serde_regex")]
26    pub regex: Option<Regex>,
27}
28
29#[derive(Deserialize, Default, Debug, Clone)]
30pub enum SchemaValueType {
31    #[default]
32    String,
33    Integer,
34    Float,
35    Boolean,
36    Url,
37    Email,
38}
39
40impl DotEnvSchema {
41    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, std::io::Error> {
42        let file = File::open(path)?;
43        let reader = BufReader::new(file);
44        let read_schema: DotEnvSchema = serde_json::from_reader(reader)?;
45        Ok(read_schema)
46    }
47}
48
49pub enum ValidateResult {
50    Valid,
51    Invalid(SchemaValueType),
52}
53
54impl SchemaEntry {
55    pub fn is_valid(&self, value: &str) -> ValidateResult {
56        match self.value_type {
57            SchemaValueType::String => {
58                let Some(regex) = &self.regex else {
59                    return ValidateResult::Valid;
60                };
61
62                if !regex.is_match(value) {
63                    return ValidateResult::Invalid(SchemaValueType::String);
64                };
65            }
66            SchemaValueType::Boolean => {
67                if !matches!(
68                    value,
69                    "true" | "false" | "TRUE" | "FALSE" | "yes" | "no" | "YES" | "NO" | "1" | "0"
70                ) {
71                    return ValidateResult::Invalid(SchemaValueType::Boolean);
72                }
73            }
74            SchemaValueType::Integer => {
75                if value.parse::<i32>().is_err() {
76                    return ValidateResult::Invalid(SchemaValueType::Integer);
77                }
78            }
79            SchemaValueType::Float => {
80                if value.parse::<f32>().is_err() {
81                    return ValidateResult::Invalid(SchemaValueType::Float);
82                }
83            }
84            SchemaValueType::Email => {
85                if !email_address::EmailAddress::is_valid(value) {
86                    return ValidateResult::Invalid(SchemaValueType::Email);
87                }
88            }
89            SchemaValueType::Url => {
90                if url::Url::parse(value).is_err() {
91                    return ValidateResult::Invalid(SchemaValueType::Url);
92                }
93            }
94        }
95
96        ValidateResult::Valid
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use std::{
103        fs::{self, File},
104        io::Write,
105    };
106
107    use tempfile::tempdir;
108
109    use super::DotEnvSchema;
110
111    #[test]
112    fn create_file_schema() {
113        let json = r#"{
114            "version": "1.0.0",
115            "entries": {
116                "NAME": {
117                    "type": "String"
118                },
119                "PORT": {
120                    "type": "Integer"
121                },
122                "PRICE": {
123                    "type": "Float"
124                },
125                "URL": {
126                    "type": "Url"
127                },
128                "EMAIL":{
129                    "type": "Email"
130                },
131                "FLAG":{
132                    "type": "Boolean"
133                }
134            }
135        }"#;
136        // write the above json to a temp file
137        let temp_dir = tempdir().expect("create temp dir");
138        let file_path = temp_dir.path().join("schema.json");
139        let schema = {
140            let mut file = File::create(&file_path).expect("create file");
141            file.write_all(json.as_bytes()).expect("write file");
142            // load the schema from the file
143            DotEnvSchema::load(&file_path)
144        };
145        fs::remove_file(&file_path).expect("remove file");
146        assert!(schema.is_ok());
147    }
148
149    #[test]
150    fn load_missing_file() {
151        assert!(DotEnvSchema::load(std::path::Path::new("bad_file.json")).is_err());
152    }
153
154    #[test]
155    fn create_bad_regex_file_schema() {
156        let json = r#"{
157            "version": "1.0.0",
158            "entries": {
159                "NAME": {
160                    "type": "String",
161                    "regex": "~[.."
162                },
163                "PORT": {
164                    "type": "Integer"
165                },
166                "PRICE": {
167                    "type": "Float"
168                },
169                "URL": {
170                    "type": "Url"
171                },
172                "EMAIL":{
173                    "type": "Email"
174                },
175                "FLAG":{
176                    "type": "Boolean"
177                }
178            }
179        }"#;
180        // write the above json to a temp file
181        let temp_dir = tempdir().expect("create temp dir");
182        let file_path = temp_dir.path().join("schema.json");
183        let schema = {
184            let mut file = File::create(&file_path).expect("create file");
185            file.write_all(json.as_bytes()).expect("write file");
186            // load the schema from the file
187            DotEnvSchema::load(&file_path)
188        };
189        fs::remove_file(&file_path).expect("remove file");
190        assert!(schema.is_err());
191    }
192
193    #[test]
194    fn create_bad_file_schema() {
195        let json = r#"{
196            "version": "1.0.0",
197            bad:json
198        }"#;
199        // write the above json to a temp file
200        let temp_dir = tempdir().expect("create temp dir");
201        let file_path = temp_dir.path().join("schema.json");
202        let schema = {
203            let mut file = File::create(&file_path).expect("create file");
204            file.write_all(json.as_bytes()).expect("write file");
205            // load the schema from the file
206            DotEnvSchema::load(&file_path)
207        };
208        fs::remove_file(&file_path).expect("remove file");
209        assert!(schema.is_err());
210    }
211    #[test]
212    fn test_dup_schema() {
213        let json = r#"{
214            "version": "1.0.0",
215            "entries": {
216                "NAME": {
217                    "type": "String"
218                },
219                "NAME": {
220                    "type": "String"
221                },
222                "PORT": {
223                    "type": "Integer"
224                },
225                "PRICE": {
226                    "type": "Float"
227                },
228                "URL": {
229                    "type": "Url"
230                },
231                "EMAIL":{
232                    "type": "Email"
233                },
234                "FLAG":{
235                    "type": "Boolean"
236                }
237            }
238        }"#;
239        let schema: serde_json::Result<DotEnvSchema> = serde_json::from_str(json);
240        assert!(schema.is_err());
241        assert_eq!(
242            schema.expect_err("deserializing schema").to_string(),
243            "invalid entry: found duplicate key at line 9 column 17"
244        );
245    }
246}