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 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 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 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 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 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 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}