you_must_conform/
lib.rs

1//! <div align="center">
2//!
3//! [![crates-io](https://img.shields.io/crates/v/you-must-conform.svg)](https://crates.io/crates/you-must-conform)
4//! [![docs-rs](https://docs.rs/you-must-conform/badge.svg)](https://docs.rs/you-must-conform)
5//! [![github](https://img.shields.io/static/v1?label=&message=github&color=grey&logo=github)](https://github.com/aatifsyed/you-must-conform)
6//!
7//! </div>
8//!
9//! A command-line tool for enforcing YAML|JSON|TOML|text file contents.
10//!
11//! # Usage
12//! ```yaml
13//! # conform.yaml
14//! config:
15//! - file: Cargo.toml
16//!   format: toml
17//!   schema:                   # Ensure these nested keys are set
18//!     package:
19//!       edition: "2021"
20//! - file: Cargo.lock
21//!   exists: true              # Ensure this file exists
22//! - file: src/lib.rs
23//!   matches-regex: '(?m)^use' # Ensure this regex is matched in the file
24//!
25//! include:                    # (Recursively) merge config from these urls
26//! - https://example.com/another-conform.yaml
27//!
28//! ```
29//!
30//! ```console
31//! $ you-must-conform --help
32//! you-must-conform 1.1.0
33//! A command-line tool for enforcing YAML|JSON|TOML|text file contents.
34//!
35//! USAGE:
36//!     you-must-conform [OPTIONS] <--file <FILE>|--url <URL>>
37//!
38//! OPTIONS:
39//!     -c, --context <CONTEXT>    The folder to check against the config file [default: .]
40//!     -f, --file <FILE>          The config file to check [default: conform.yaml]
41//!     -h, --help                 Print help information
42//!     -u, --url <URL>            A url to fetch the config file from instead
43//!     -V, --version              Print version information
44//!
45//! $ you-must-conform
46//! Schema not matched in ./Cargo.toml:
47//!     "package" is a required property
48//! File ./Cargo.lock does not exist
49//! File ./src/lib.rs does not match regex (?m)^use
50//! Error: Found 3 problems
51//! ```
52
53use 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                            // Read to string since `toml` doesn't have a from_reader
134                            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}