1use anyhow::Context;
54use itertools::Itertools;
55use jsonschema::{JSONSchema, ValidationError};
56use regex::Regex;
57use serde::{Deserialize, Serialize};
58use std::{
59 borrow::Cow,
60 fs, io,
61 path::{Path, PathBuf},
62};
63mod json;
64
65use crate::json::describe_value;
66
67#[derive(Debug, Serialize, Deserialize)]
68#[serde(untagged)]
69pub enum CheckItem {
70 File {
71 file: PathBuf,
72 #[serde(flatten)]
73 check: FileCheck,
74 },
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79#[serde(untagged)]
80pub enum FileCheck {
81 Exists {
82 exists: bool,
83 },
84 LooksLike {
85 format: FileFormat,
86 schema: serde_json::Value,
87 },
88 #[serde(rename_all = "kebab-case")]
89 MatchesRegex {
90 #[serde(with = "serde_regex")]
91 matches_regex: Regex,
92 },
93}
94
95#[derive(Debug, Serialize, Deserialize, Clone, Copy, strum::IntoStaticStr)]
96#[serde(rename_all = "kebab-case")]
97pub enum FileFormat {
98 Json,
99 Toml,
100 Yaml,
101}
102
103pub fn check_items(
104 root: impl AsRef<Path>,
105 items: impl IntoIterator<Item = CheckItem>,
106) -> anyhow::Result<Vec<Problem>> {
107 use Problem::{
108 DisallowedFile, FileNotPresent, InvalidFormat, RegexNotMatched, SchemaNotMatched,
109 };
110 let mut problems = Vec::new();
111 let root = root.as_ref().to_owned();
112 for item in items {
113 match item {
114 CheckItem::File { file, check } => {
115 let path = root.join(file);
116 match check {
117 FileCheck::Exists {
118 exists: should_exist,
119 } => match path.metadata() {
120 Ok(meta) if meta.is_file() && !should_exist => {
121 problems.push(DisallowedFile(path))
122 }
123 Err(err) if err.kind() == io::ErrorKind::NotFound && should_exist => {
124 problems.push(FileNotPresent(path))
125 }
126 _ => (),
127 },
128 FileCheck::LooksLike {
129 format,
130 schema: like,
131 } => match path.is_file() {
132 true => {
133 let s = fs::read_to_string(&path)
135 .context(format!("Couldn't read {}", path.display()))?;
136 let deser_result = match format {
137 FileFormat::Json => {
138 serde_json::from_str(&s).map_err(anyhow::Error::new)
139 }
140 FileFormat::Toml => toml::from_str(&s).map_err(anyhow::Error::new),
141 FileFormat::Yaml => {
142 serde_yaml::from_str(&s).map_err(anyhow::Error::new)
143 }
144 };
145 match deser_result {
146 Ok(v) => {
147 let schema = JSONSchema::compile(&describe_value(&like))
148 .expect("Autogenerated schema generation failed, please file a bug report.");
149
150 if let Err(errors) = schema.validate(&v) {
151 problems.push(SchemaNotMatched {
152 path,
153 errors: errors
154 .map(|validation_error| ValidationError {
155 instance: Cow::Owned(
156 validation_error.instance.into_owned(),
157 ),
158 ..validation_error
159 })
160 .collect(),
161 })
162 };
163 }
164 Err(err) => problems.push(InvalidFormat {
165 path,
166 format: format.into(),
167 err,
168 }),
169 }
170 }
171 false => problems.push(FileNotPresent(path)),
172 },
173 FileCheck::MatchesRegex {
174 matches_regex: regex,
175 } => match path.is_file() {
176 true => {
177 let s = fs::read_to_string(&path)
178 .context(format!("Couldn't read {}", path.display()))?;
179 if !regex.is_match(&s) {
180 problems.push(RegexNotMatched { path, regex })
181 }
182 }
183 false => problems.push(FileNotPresent(path)),
184 },
185 }
186 }
187 }
188 }
189 Ok(problems)
190}
191
192#[derive(Debug, thiserror::Error)]
193pub enum Problem {
194 #[error("File {} couldn't be read in as {format}: {err:?}", .path.display())]
195 InvalidFormat {
196 path: PathBuf,
197 format: &'static str,
198 err: anyhow::Error,
199 },
200 #[error("Schema not matched in {}:\n\t{}", .path.display(), .errors.iter().join("\n\t"))]
201 SchemaNotMatched {
202 path: PathBuf,
203 errors: Vec<ValidationError<'static>>,
204 },
205 #[error("File {} does not match regex {regex}", .path.display())]
206 RegexNotMatched { path: PathBuf, regex: Regex },
207 #[error("File {} does not exist", .0.display())]
208 FileNotPresent(PathBuf),
209 #[error("File {} is not allowed to exist", .0.display())]
210 DisallowedFile(PathBuf),
211}
212
213impl CheckItem {
214 pub fn file(file: impl AsRef<Path>, check: FileCheck) -> Self {
215 Self::File {
216 file: file.as_ref().to_owned(),
217 check,
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use regex::Regex;
225 use serde_json::json;
226 use std::fs::{self, File};
227 use tempfile::tempdir;
228
229 use crate::{check_items, CheckItem, FileCheck, FileFormat, Problem};
230
231 #[test]
232 fn empty_directory() -> anyhow::Result<()> {
233 let d = tempdir()?;
234 let problems = check_items(d, [])?;
235 println!("{problems:?}");
236 assert!(matches!(problems.as_slice(), []));
237 Ok(())
238 }
239
240 #[test]
241 fn file_existence() -> anyhow::Result<()> {
242 let d = tempdir()?;
243 File::create(d.path().join("foo"))?;
244
245 let problems = check_items(
246 &d,
247 [CheckItem::file("foo", FileCheck::Exists { exists: true })],
248 )?;
249 println!("{problems:?}");
250 assert!(matches!(problems.as_slice(), []));
251
252 let problems = check_items(
253 &d,
254 [CheckItem::file("foo", FileCheck::Exists { exists: false })],
255 )?;
256 println!("{problems:?}");
257 assert!(matches!(problems.as_slice(), [Problem::DisallowedFile(_)]));
258
259 Ok(())
260 }
261
262 #[test]
263 fn schema_validation() -> anyhow::Result<()> {
264 let d = tempdir()?;
265 fs::write(d.path().join("foo.toml"), "[hello]\nworld = true")?;
266 let problems = check_items(
267 &d,
268 [CheckItem::file(
269 "foo.toml",
270 FileCheck::LooksLike {
271 format: FileFormat::Toml,
272 schema: json!({"hello": {"world": true}}),
273 },
274 )],
275 )?;
276 println!("{problems:?}");
277 assert!(matches!(problems.as_slice(), []));
278
279 let problems = check_items(
280 &d,
281 [CheckItem::file(
282 "foo.toml",
283 FileCheck::LooksLike {
284 format: FileFormat::Toml,
285 schema: json!({"hello": {"world": false}}),
286 },
287 )],
288 )?;
289 println!("{problems:?}");
290 assert!(matches!(
291 problems.as_slice(),
292 [Problem::SchemaNotMatched { .. }]
293 ));
294
295 Ok(())
296 }
297
298 #[test]
299 fn regex_matching() -> anyhow::Result<()> {
300 let d = tempdir()?;
301 fs::write(d.path().join("bar"), "barometer\nbartholomew\nbartender")?;
302 let problems = check_items(
303 &d,
304 [CheckItem::file(
305 "bar",
306 FileCheck::MatchesRegex {
307 matches_regex: Regex::new("barth")?,
308 },
309 )],
310 )?;
311 println!("{problems:?}");
312 assert!(matches!(problems.as_slice(), []));
313
314 let problems = check_items(
315 &d,
316 [CheckItem::file(
317 "bar",
318 FileCheck::MatchesRegex {
319 matches_regex: Regex::new("foo")?,
320 },
321 )],
322 )?;
323 println!("{problems:?}");
324 assert!(matches!(
325 problems.as_slice(),
326 [Problem::RegexNotMatched { .. }]
327 ));
328
329 Ok(())
330 }
331}