libscilo/lints/
check.rs

1//! Structs and functions to organize and perform lint checks.
2
3use log::{debug, info};
4use regex::Regex;
5use std::{
6    cmp::Ordering,
7    iter::{Filter, FilterMap},
8    path::Path,
9};
10use strum::{EnumIter, EnumString};
11use walkdir::{DirEntry, Error, IntoIter, WalkDir};
12
13use super::LintError;
14use crate::InstantiatedConfig;
15
16/// The linting checks that are currently supported.
17///
18/// Lint codes available in a configuration file are `snake_case` versions of
19/// the enum names here.
20#[derive(Clone, Copy, Debug, strum::Display, EnumIter, EnumString)]
21#[strum(serialize_all = "snake_case")]
22pub enum LintCheck {
23    /// Check that every subdirectory within the project's [`code`][crate::RootDirs::code]
24    /// directory has a corresponding subdirectory with the same name in the
25    /// [`results`][crate::RootDirs::results] directory, and vice versa.
26    CodeResultsSubdirPairing,
27
28    /// All subdirectories within the project's [`code`][crate::RootDirs::code]
29    /// and [`results`][crate::RootDirs::results] directory should
30    /// have names that match the regular expression in
31    /// [`code_results_subdir_regex`][crate::InstantiatedConfig::code_results_subdir_regex].
32    CodeResultsSubdirRegex,
33
34    /// Each subdirectory within the project's [`code`][crate::RootDirs::code]
35    /// directory should contain a README file.
36    CodeSubdirReadmes,
37
38    /// Each subdirectory within the project's [`code`][crate::RootDirs::code]
39    /// directory should contain a workflow file.
40    CodeSubdirWorkflows,
41
42    /// Each subdirectory within the project's [`data`][crate::RootDirs::data]
43    /// directory should contain a README file.
44    DataSubdirReadmes,
45
46    /// Check for a set of required directories that should be present in the
47    /// project's root directory.
48    RootDirs,
49
50    /// Check for a set of required files that should be present in the
51    /// project's root directory.
52    RootFiles,
53}
54
55impl LintCheck {
56    /// Execute a lint check.
57    ///
58    /// This is effectively a large match statement that dispatches the relevant
59    /// checks to other functions.
60    pub fn check(self, cfg: &InstantiatedConfig) -> Result<(), LintError> {
61        info!("Checking lint: {}", self);
62        match self {
63            Self::CodeResultsSubdirPairing => {
64                if let (Some(code), Some(results)) = (&cfg.root_dirs.code, &cfg.root_dirs.results) {
65                    code_results_subdir_pairing(code, results)?;
66                }
67            }
68            Self::CodeResultsSubdirRegex => {
69                if let (Some(code), Some(re)) =
70                    (&cfg.root_dirs.code, &cfg.code_results_subdir_regex)
71                {
72                    code_results_dir_regex(code, re)?;
73                }
74                if let (Some(results), Some(re)) =
75                    (&cfg.root_dirs.results, &cfg.code_results_subdir_regex)
76                {
77                    code_results_dir_regex(results, re)?;
78                }
79            }
80            Self::CodeSubdirReadmes => {
81                if let (Some(code), Some(readmes)) = (&cfg.root_dirs.code, &cfg.readme_names) {
82                    // this remaps the variable so the input type matches the function signature
83                    let readmes = readmes.iter().map(String::as_str).collect();
84                    subdir_readmes(code, readmes)?;
85                }
86            }
87            Self::CodeSubdirWorkflows => {
88                if let (Some(code), Some(workflows)) = (&cfg.root_dirs.code, &cfg.workflow_names) {
89                    // this remaps the variable so the input type matches the function signature
90                    let workflows = workflows.iter().map(String::as_str).collect();
91                    subdir_workflows(code, workflows)?;
92                }
93            }
94            Self::DataSubdirReadmes => {
95                if let (Some(data), Some(readmes)) = (&cfg.root_dirs.data, &cfg.readme_names) {
96                    // this remaps the variable so the input type matches the function signature
97                    let readmes = readmes.iter().map(String::as_str).collect();
98                    subdir_readmes(data, readmes)?;
99                }
100            }
101            Self::RootDirs => {
102                let root_dirs = [
103                    cfg.root_dirs.code.clone(),
104                    cfg.root_dirs.data.clone(),
105                    cfg.root_dirs.docs.clone(),
106                    cfg.root_dirs.external.clone(),
107                    cfg.root_dirs.results.clone(),
108                ];
109                for dir in root_dirs.iter().filter_map(|d| d.as_ref()) {
110                    dir_exists(dir)?;
111                }
112            }
113            Self::RootFiles => {
114                for file in &cfg.root_files {
115                    file_exists(file)?;
116                }
117            }
118        }
119
120        Ok(())
121    }
122}
123
124/// A helper function to set up a directory walker within a given directory.
125///
126/// This ignores symlinks.
127/// This also guarantees that the entries will be walked over in an order, sorted by file name.
128fn directory_walker(
129    path: &Path,
130) -> Filter<
131    FilterMap<IntoIter, impl FnMut(Result<DirEntry, Error>) -> Option<DirEntry>>,
132    impl FnMut(&DirEntry) -> bool,
133> {
134    WalkDir::new(path)
135        .max_depth(1)
136        .min_depth(1)
137        .sort_by(|a, b| a.file_name().cmp(b.file_name()))
138        .into_iter()
139        .filter_map(|e| e.ok())
140        .filter(|e| match e.metadata() {
141            Ok(meta) => meta.is_dir(),
142            Err(_) => false,
143        })
144}
145
146/// Check that every subdirectory within [`code`][crate::RootDirs::code]
147/// has a corresponding subdirectory in [`results`][crate::RootDirs::results]
148/// and vice versa.
149pub(crate) fn code_results_subdir_pairing(code: &Path, results: &Path) -> Result<(), LintError> {
150    let mut code_walker = directory_walker(code);
151    let mut results_walker = directory_walker(results);
152
153    let mut code_subdir_opt = code_walker.next();
154    let mut results_subdir_opt = results_walker.next();
155
156    loop {
157        // because these are guaranteed to be in sorted order, we just need to
158        // compare them element by element
159        match (code_subdir_opt, results_subdir_opt) {
160            (None, None) => break,
161            (Some(code_subdir), None) => {
162                let formatted_code_subdir_name = code.join(code_subdir.file_name()).to_path_buf();
163                let formatted_results_subdir_name =
164                    results.join(code_subdir.file_name()).to_path_buf();
165                return Err(LintError::CodeSubdirectoryMissingInResults {
166                    code: formatted_code_subdir_name,
167                    results: formatted_results_subdir_name,
168                });
169            }
170            (None, Some(results_subdir)) => {
171                let formatted_results_subdir_name =
172                    results.join(results_subdir.file_name()).to_path_buf();
173                let formatted_code_subdir_name =
174                    code.join(results_subdir.file_name()).to_path_buf();
175                return Err(LintError::ResultsSubdirectoryMissingInCode {
176                    code: formatted_code_subdir_name,
177                    results: formatted_results_subdir_name,
178                });
179            }
180            (Some(code_subdir), Some(results_subdir)) => {
181                match code_subdir.file_name().cmp(results_subdir.file_name()) {
182                    Ordering::Equal => {
183                        // move to the next set of item to compare
184                        code_subdir_opt = code_walker.next();
185                        results_subdir_opt = results_walker.next();
186                    }
187                    Ordering::Less => {
188                        let formatted_code_subdir_name =
189                            code.join(code_subdir.file_name()).to_path_buf();
190                        let formatted_results_subdir_name =
191                            results.join(code_subdir.file_name()).to_path_buf();
192                        return Err(LintError::CodeSubdirectoryMissingInResults {
193                            code: formatted_code_subdir_name,
194                            results: formatted_results_subdir_name,
195                        });
196                    }
197                    Ordering::Greater => {
198                        let formatted_results_subdir_name =
199                            results.join(results_subdir.file_name()).to_path_buf();
200                        let formatted_code_subdir_name =
201                            code.join(results_subdir.file_name()).to_path_buf();
202                        return Err(LintError::ResultsSubdirectoryMissingInCode {
203                            code: formatted_code_subdir_name,
204                            results: formatted_results_subdir_name,
205                        });
206                    }
207                }
208            }
209        }
210    }
211
212    Ok(())
213}
214
215/// Check that the directories under the [`code`][crate::RootDirs::code]
216/// and [`results`][crate::RootDirs::results] follow the correct style.
217pub(crate) fn code_results_dir_regex(path: &Path, re: &Regex) -> Result<(), LintError> {
218    let walker = directory_walker(path);
219    for entry in walker {
220        if let Some(dir_name) = entry.path().file_name() {
221            if let Some(dir_str) = dir_name.to_str() {
222                if !re.is_match_at(dir_str, 0) {
223                    return Err(LintError::CodeResultsSubdirectoryRegexMismatch(
224                        entry.into_path(),
225                        re.to_string(),
226                    ));
227                }
228            }
229        }
230    }
231    Ok(())
232}
233
234/// Check that a required directory exists.
235///
236/// This is a wrapper around [`Path::is_dir()`] that returns the correct [`LintError`].
237pub fn dir_exists(path: &Path) -> Result<(), LintError> {
238    if path.is_dir() && path.exists() {
239        Ok(())
240    } else {
241        Err(LintError::RequiredDirectoryDoesNotExist(path.to_path_buf()))
242    }
243}
244
245/// Check that a required file exists.
246///
247/// This is a wrapper around [`Path::is_file()`] that returns the correct [`LintError`].
248pub fn file_exists(path: &Path) -> Result<(), LintError> {
249    if path.is_file() && path.exists() {
250        Ok(())
251    } else {
252        Err(LintError::RequiredFileDoesNotExist(path.to_path_buf()))
253    }
254}
255
256/// Check for the presence of README files within each subdirectory of a [root directory][crate::RootDirs].
257fn subdir_readmes(path: &Path, readmes: Vec<&str>) -> Result<(), LintError> {
258    debug!("Checking for READMEs in {}", path.display());
259    let walker = directory_walker(path);
260    for entry in walker {
261        debug!("Checking for README in {}", entry.path().display());
262        let mut subdir_readme = false;
263
264        for readme in &readmes {
265            let readme_path = entry.path().join(readme);
266            debug!("Checking for {}", readme_path.display());
267            if readme_path.exists() {
268                subdir_readme = true;
269            }
270        }
271
272        if !subdir_readme {
273            return Err(LintError::SubdirectoryMissingReadme(
274                entry.path().to_path_buf(),
275            ));
276        }
277    }
278
279    Ok(())
280}
281
282/// Check for the presence of workflow files within each [`code`][crate::RootDirs::code] subdirectory.
283fn subdir_workflows(path: &Path, workflows: Vec<&str>) -> Result<(), LintError> {
284    debug!("Checking for workflow files in {}", path.display());
285    let walker = directory_walker(path);
286    for entry in walker {
287        debug!("Checking for workflow file in {}", entry.path().display());
288        let mut subdir_workflow = false;
289
290        for workflow in &workflows {
291            let workflow_path = entry.path().join(workflow);
292            debug!("Checking for {}", workflow_path.display());
293            if workflow_path.exists() {
294                subdir_workflow = true;
295            }
296        }
297
298        if !subdir_workflow {
299            return Err(LintError::SubdirectoryMissingWorkflow(
300                entry.path().to_path_buf(),
301            ));
302        }
303    }
304
305    Ok(())
306}