Skip to main content

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