alpm_lint/
scope.rs

1//! Representation and handling of linting scopes.
2
3use std::{
4    collections::HashSet,
5    fmt::Display,
6    fs::{metadata, read_dir},
7    path::Path,
8};
9
10use alpm_types::{MetadataFileName, PKGBUILD_FILE_NAME, SRCINFO_FILE_NAME};
11use clap::ValueEnum;
12use serde::{Deserialize, Serialize};
13use strum::{Display as StrumDisplay, VariantArray};
14
15use crate::Error;
16
17/// The fully qualified name of a lint rule.
18///
19/// A [`ScopedName`] combines the [`LintScope`] with the rule’s identifier,
20/// forming a unique name in the format `{scope}::{name}`.
21///
22/// # Examples
23///
24/// ```
25/// use alpm_lint::{LintScope, ScopedName};
26///
27/// let name = ScopedName::new(LintScope::SourceRepository, "my_rule");
28/// assert_eq!("source_repository::my_rule", name.to_string());
29/// ```
30#[derive(Clone, Debug, PartialEq)]
31pub struct ScopedName {
32    scope: LintScope,
33    name: &'static str,
34}
35
36impl ScopedName {
37    /// Create a new instance of [`ScopedName`]
38    pub fn new(scope: LintScope, name: &'static str) -> Self {
39        Self { scope, name }
40    }
41}
42
43impl Display for ScopedName {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{}::{}", self.scope, self.name)
46    }
47}
48
49/// The possible scope used to categorize lint rules.
50///
51/// Scopes are used to determine what lints should be executed based on a specific linting
52/// operation. For example, selecting [`LintScope::SourceInfo`] will run all
53/// [`SourceInfo`](alpm_srcinfo::SourceInfo) specific linting rules. Linting scopes can also be
54/// fully enabled or disabled via configuration files.
55#[derive(
56    Clone, Copy, Debug, Deserialize, PartialEq, Serialize, StrumDisplay, ValueEnum, VariantArray,
57)]
58#[strum(serialize_all = "snake_case")]
59pub enum LintScope {
60    /// Lint rules with this scope are specific to an [alpm-source-repo].
61    ///
62    /// Such lint rules check the consistency of an Arch Linux package source repository.
63    /// This includes the consistency of data between several metadata files.
64    ///
65    /// When this scope is selected, the following lint scopes are implied:
66    /// - [`LintScope::PackageBuild`]
67    /// - [`LintScope::SourceInfo`]
68    ///
69    /// [alpm-source-repo]: https://alpm.archlinux.page/specifications/alpm-source-repo.7.html
70    SourceRepository,
71    /// Lint rules with this scope are specific to an [alpm-package].
72    ///
73    /// Such lint rules check the consistency of an Arch Linux package file.
74    /// This includes the consistency of data between various metadata files.
75    ///
76    /// When this scope is selected, the following lint scopes are implied:
77    /// - [`LintScope::PackageInfo`]
78    /// - [`LintScope::BuildInfo`]
79    ///
80    /// [alpm-package]: https://alpm.archlinux.page/specifications/alpm-package.7.html
81    Package,
82    /// Lint rules with this scope are specific to a single [BUILDINFO] file.
83    ///
84    /// [BUILDINFO]: https://alpm.archlinux.page/specifications/BUILDINFO.5.html
85    BuildInfo,
86    /// Lint rules with this scope are specific to a single [PKGBUILD] file.
87    ///
88    /// [PKGBUILD]: https://man.archlinux.org/man/PKGBUILD.5
89    PackageBuild,
90    /// Lint rules with this scope are specific to a single [PKGINFO] file.
91    ///
92    /// [PKGINFO]: https://alpm.archlinux.page/specifications/PKGINFO.5.html
93    PackageInfo,
94    /// Lint rules with this scope are specific to a single [SRCINFO] file.
95    ///
96    /// [SRCINFO]: https://alpm.archlinux.page/specifications/SRCINFO.5.html
97    SourceInfo,
98}
99
100impl LintScope {
101    /// Determines whether a [`LintScope`] contains or matches another.
102    ///
103    /// In this context "contains" and "matches" means that either `self` is identical to `other`,
104    /// or that the scope of `other` is contained in the scope of `self`.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use alpm_lint::LintScope;
110    ///
111    /// let source_info = LintScope::SourceInfo;
112    /// let source_repo = LintScope::SourceRepository;
113    ///
114    /// assert!(source_repo.contains(&source_info));
115    /// assert!(source_info.contains(&source_info));
116    /// assert!(!source_info.contains(&source_repo));
117    /// ```
118    pub fn contains(&self, other: &LintScope) -> bool {
119        match self {
120            // A `SourceRepository` scope may contain a SourceInfo or PackageBuild file.
121            LintScope::SourceRepository => match other {
122                LintScope::SourceRepository | LintScope::SourceInfo | LintScope::PackageBuild => {
123                    true
124                }
125                LintScope::BuildInfo | LintScope::PackageInfo | LintScope::Package => false,
126            },
127            // A `Package` scope may contain a PackageBuild or PackageInfo file.
128            LintScope::Package => match other {
129                LintScope::Package | LintScope::PackageBuild | LintScope::PackageInfo => true,
130                LintScope::BuildInfo | LintScope::SourceRepository | LintScope::SourceInfo => false,
131            },
132            // All scopes that are restricted to a single file require the exact same scope.
133            LintScope::BuildInfo
134            | LintScope::PackageBuild
135            | LintScope::PackageInfo
136            | LintScope::SourceInfo => self == other,
137        }
138    }
139
140    /// Attempts to return all applicable lint scopes based on a provided `path`.
141    ///
142    /// Usually, when calling `alpm-lint check`, [`LintScope::detect`] is used to
143    /// automatically determine the available linting scope based on files in the specified
144    /// directory. The current scope can also be overridden by the user.
145    ///
146    /// Based on that scope, files will be loaded and linting rules are selected for execution.
147    ///
148    /// # Errors
149    ///
150    /// - The path cannot be read/accessed
151    /// - The scope cannot be determined based on the file/s at the given path.
152    pub fn detect(path: &Path) -> Result<LintScope, Error> {
153        // `metadata` automatically follows symlinks, so we get the target's metadata
154        let metadata = metadata(path).map_err(|source| Error::IoPath {
155            path: path.to_owned(),
156            context: "getting metadata of path",
157            source,
158        })?;
159
160        // Handle the case where the path is a single file.
161        if metadata.is_file() {
162            let filename = path.file_name().ok_or(Error::NoLintScope {
163                path: path.to_owned(),
164            })?;
165
166            // Package source repository related scopes
167            if filename == alpm_types::PKGBUILD_FILE_NAME {
168                return Ok(LintScope::PackageBuild);
169            } else if filename == alpm_types::SRCINFO_FILE_NAME {
170                return Ok(LintScope::SourceInfo);
171            // Package related scopes
172            } else if filename == Into::<&'static str>::into(MetadataFileName::BuildInfo) {
173                return Ok(LintScope::BuildInfo);
174            } else if filename == Into::<&'static str>::into(MetadataFileName::PackageInfo) {
175                return Ok(LintScope::PackageInfo);
176            } else {
177                return Err(Error::NoLintScope {
178                    path: path.to_path_buf(),
179                });
180            }
181        }
182
183        // At this point, we know that this is a directory.
184        // Look at the contained files and try to figure out which scope fits best.
185
186        let entries = read_dir(path).map_err(|source| Error::IoPath {
187            path: path.to_owned(),
188            context: "read directory entries",
189            source,
190        })?;
191
192        let mut filenames = HashSet::new();
193
194        // Create a hashmap of filenames, so that we can easily determine which alpm files exist in
195        // the directory.
196        for entry in entries {
197            let entry = entry.map_err(|source| Error::IoPath {
198                path: path.to_owned(),
199                context: "read a specific directory entries",
200                source,
201            })?;
202            let entry_path = entry.path();
203            let metadata = entry.metadata().map_err(|source| Error::IoPath {
204                path: entry_path.to_owned(),
205                context: "getting metadata of file",
206                source,
207            })?;
208
209            // Make sure that the entry is a file. We're only interested in files for now.
210            if !metadata.is_file() {
211                continue;
212            }
213
214            let Some(filename) = entry_path.file_name() else {
215                continue;
216            };
217            filenames.insert(filename.to_string_lossy().to_string());
218        }
219
220        if filenames.contains(PKGBUILD_FILE_NAME) && filenames.contains(SRCINFO_FILE_NAME) {
221            Ok(LintScope::SourceRepository)
222        } else if filenames.contains(MetadataFileName::BuildInfo.into())
223            && filenames.contains(MetadataFileName::PackageInfo.into())
224        {
225            Ok(LintScope::Package)
226        } else if filenames.contains(PKGBUILD_FILE_NAME) {
227            Ok(LintScope::PackageBuild)
228        } else if filenames.contains(SRCINFO_FILE_NAME) {
229            Ok(LintScope::SourceInfo)
230        } else if filenames.contains(MetadataFileName::BuildInfo.into()) {
231            Ok(LintScope::BuildInfo)
232        } else if filenames.contains(MetadataFileName::PackageInfo.into()) {
233            Ok(LintScope::PackageInfo)
234        } else {
235            Err(Error::NoLintScope {
236                path: path.to_path_buf(),
237            })
238        }
239    }
240
241    /// Checks whether the [`LintScope`] is for a single file.
242    pub fn is_single_file(&self) -> bool {
243        match self {
244            LintScope::SourceRepository | LintScope::Package => false,
245            LintScope::BuildInfo
246            | LintScope::PackageBuild
247            | LintScope::PackageInfo
248            | LintScope::SourceInfo => true,
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use std::fs::File;
256
257    use rstest::rstest;
258    use testresult::{TestError, TestResult};
259
260    use super::*;
261
262    /// Ensure that the correct scope is detected based on existing files in the given directory.
263    #[rstest]
264    #[case::package(vec!["PKGBUILD", ".SRCINFO"], LintScope::SourceRepository)]
265    #[case::package_with_other_files(vec!["test_file", "PKGBUILD", ".SRCINFO", ".BUILDINFO", ".PKGINFO"], LintScope::SourceRepository)]
266    #[case::package_build(vec!["PKGBUILD"], LintScope::PackageBuild)]
267    #[case::source_info(vec![".SRCINFO"], LintScope::SourceInfo)]
268    #[case::source_repo(vec![".BUILDINFO", ".PKGINFO"], LintScope::Package)]
269    #[case::source_repo_with_other_files(vec!["test_file", "PKGBUILD", ".BUILDINFO", ".PKGINFO"], LintScope::Package)]
270    #[case::build_info(vec![".BUILDINFO"], LintScope::BuildInfo)]
271    #[case::package_info(vec![".PKGINFO"], LintScope::PackageInfo)]
272    fn detect_scope_in_directory(
273        #[case] files: Vec<&'static str>,
274        #[case] expected: LintScope,
275    ) -> TestResult<()> {
276        // Create a temporary directory for testing.
277        let tmp_dir = tempfile::tempdir()?;
278
279        // Create all files
280        for name in &files {
281            let path = tmp_dir.path().join(name);
282            File::create(&path)?;
283        }
284
285        let scope = LintScope::detect(tmp_dir.path())?;
286
287        assert_eq!(
288            scope, expected,
289            "Expected '{expected}' scope for file set {files:?}"
290        );
291
292        Ok(())
293    }
294
295    /// Ensure that the correct scope is detected based on existing files in the given directory.
296    #[rstest]
297    #[case::unknown_files(vec!["test_file", "test_file2"])]
298    #[case::no_files(vec![])]
299    fn fail_to_detect_scope_in_directory(#[case] files: Vec<&'static str>) -> TestResult<()> {
300        // Create a temporary directory for testing.
301        let tmp_dir = tempfile::tempdir()?;
302
303        // Create all files
304        for name in &files {
305            let path = tmp_dir.path().join(name);
306            File::create(&path)?;
307        }
308
309        let error = match LintScope::detect(tmp_dir.path()) {
310            Ok(scope) => {
311                return Err(TestError::from(format!(
312                    "Expected an error for scope detection for file set {files:?}, got {scope}"
313                )));
314            }
315            Err(err) => err,
316        };
317
318        assert!(
319            matches!(error, Error::NoLintScope { .. }),
320            "Expected 'NoLintScope' error for file set {files:?}"
321        );
322
323        Ok(())
324    }
325
326    /// Ensure that the correct scope is detected based on a given single file.
327    #[rstest]
328    #[case::package_build("PKGBUILD", LintScope::PackageBuild)]
329    #[case::source_info(".SRCINFO", LintScope::SourceInfo)]
330    #[case::build_info(".BUILDINFO", LintScope::BuildInfo)]
331    #[case::package_info(".PKGINFO", LintScope::PackageInfo)]
332    fn detect_scope_of_file(
333        #[case] file: &'static str,
334        #[case] expected: LintScope,
335    ) -> TestResult<()> {
336        // Create a temporary directory for testing.
337        let tmp_dir = tempfile::tempdir()?;
338
339        // Create all files
340        let path = tmp_dir.path().join(file);
341        File::create(&path)?;
342
343        let scope = LintScope::detect(&path)?;
344
345        assert_eq!(
346            scope, expected,
347            "Expected '{expected}' scope for file {file:?}"
348        );
349        assert!(scope.is_single_file());
350
351        Ok(())
352    }
353}