alpm_lint/lint_rules/
store.rs

1//! Access and filtering to all registered lints.
2//!
3//! # Note
4//!
5//! All lints need to be registered in the private `LintStore::register` function when adding a new
6//! lint rule!
7
8use std::{
9    collections::{BTreeMap, btree_map},
10    fmt,
11};
12
13use alpm_lint_config::{LintConfiguration, LintRuleConfiguration, LintRuleConfigurationOptionName};
14use serde::Serialize;
15
16use crate::{
17    ScopedName,
18    internal_prelude::{Level, LintGroup, LintRule, LintScope},
19    lint_rules::source_info::{
20        duplicate_architecture::DuplicateArchitecture,
21        invalid_spdx_license::NotSPDX,
22        no_architecture::NoArchitecture,
23        openpgp_key_id::OpenPGPKeyId,
24        undefined_architecture::UndefinedArchitecture,
25        unsafe_checksum::UnsafeChecksum,
26    },
27};
28
29/// The data representation of a singular lint rule.
30///
31/// This is used to expose lints via the CLI so that the lints can be used in website generation or
32/// for development integration.
33#[derive(Clone, Debug, Serialize)]
34pub struct SerializableLintRule {
35    name: String,
36    scoped_name: String,
37    scope: LintScope,
38    level: Level,
39    groups: Vec<LintGroup>,
40    documentation: String,
41    option_names: Vec<String>,
42}
43
44/// The constructor function type that is used by each implementation of [`LintRule`].
45///
46/// E.g. [`DuplicateArchitecture::new_boxed`]. These constructors are saved in the [`LintStore`].
47type LintConstructor = fn(&LintRuleConfiguration) -> Box<dyn LintRule>;
48
49/// A map of lint rule name and generic [`LintRule`] implementations.
50///
51/// Used in [`LintStore`] to describe tuples of lint rule names and [`LintRule`] implementations.
52type LintMap = BTreeMap<String, Box<dyn LintRule>>;
53
54/// The [`LintStore`], which contains all available and known lint rules.
55///
56/// It can be used to further filter and select lints based on various criteria.
57pub struct LintStore {
58    config: LintConfiguration,
59    lint_constructors: Vec<LintConstructor>,
60    initialized_lints: LintMap,
61}
62
63impl fmt::Debug for LintStore {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.debug_struct("LintStore")
66            .field("config", &self.config)
67            .field("lint_constructors", &"Vec<LintConstructor>")
68            .field("initialized_lints", &"LintMap")
69            .finish()
70    }
71}
72
73impl LintStore {
74    /// Creates a new [`LintStore`].
75    ///
76    /// This adds all known lint rules to the store.
77    pub fn new(config: LintConfiguration) -> Self {
78        let mut store = Self {
79            config,
80            lint_constructors: Vec::new(),
81            initialized_lints: BTreeMap::new(),
82        };
83        store.register();
84        store.initialize_lint_rules();
85
86        store
87    }
88
89    /// Registers all lints that are made available in the store.
90    ///
91    /// # Note
92    ///
93    /// New lints must be specified in this function!
94    fn register(&mut self) {
95        // **IMPORTANT** NOTE: ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
96        // When you edit this, please sort the array while at it :)
97        // Much appreciated!
98        self.lint_constructors = vec![
99            DuplicateArchitecture::new_boxed,
100            NoArchitecture::new_boxed,
101            NotSPDX::new_boxed,
102            OpenPGPKeyId::new_boxed,
103            UndefinedArchitecture::new_boxed,
104            UnsafeChecksum::new_boxed,
105        ];
106    }
107
108    /// Initializes and configures all linting rules.
109    ///
110    /// This function instantly returns if the lints have already been initialized.
111    fn initialize_lint_rules(&mut self) {
112        // Early return if the lints are already initialized.
113        if !self.initialized_lints.is_empty() {
114            return;
115        }
116
117        for lint in &self.lint_constructors {
118            let initialized = lint(&self.config.options);
119
120            self.initialized_lints
121                .insert(initialized.scoped_name(), initialized);
122        }
123    }
124
125    /// Returns a reference to the map of all available and configured lint rules.
126    pub fn lint_rules(&self) -> &LintMap {
127        &self.initialized_lints
128    }
129
130    /// Returns a specific lint rule by its scoped name.
131    ///
132    /// Returns [`None`] if no lint rule with a matching `name` exists.
133    // False positive lint warning on the return type.
134    #[allow(clippy::borrowed_box)]
135    pub fn lint_rule_by_name(&self, name: &ScopedName) -> Option<&Box<dyn LintRule>> {
136        self.initialized_lints.get(&name.to_string())
137    }
138
139    /// Returns a map of all available and configured lint rules as [`SerializableLintRule`].
140    pub fn serializable_lint_rules(&self) -> BTreeMap<String, SerializableLintRule> {
141        let mut map = BTreeMap::new();
142        for (scoped_name, lint) in &self.initialized_lints {
143            // Make sure that there's no duplicate key.
144            // We explicitly choose a `panic` as this is considered a hard consistency error.
145            //
146            // This is also covered by a test case, so it should really never happen in a release.
147            if map.contains_key(scoped_name) {
148                panic!("Encountered duplicate lint with name: {scoped_name}");
149            }
150
151            map.insert(
152                scoped_name.clone(),
153                SerializableLintRule {
154                    name: lint.name().to_string(),
155                    scoped_name: scoped_name.clone(),
156                    scope: lint.scope(),
157                    level: lint.level(),
158                    groups: lint.groups().to_vec(),
159                    documentation: lint.documentation().to_string(),
160                    option_names: lint
161                        .configuration_options()
162                        .iter()
163                        .map(LintRuleConfigurationOptionName::to_string)
164                        .collect(),
165                },
166            );
167        }
168
169        map
170    }
171
172    /// Returns lint rules that match a filter consisting of [`LintScope`] and [`Level`].
173    ///
174    /// This function filters out all lint rules that are not explicitly included **and**
175    /// - assigned to a deactivated group,
176    /// - **or** have a level above the max_level,
177    /// - **or** are explicitly ignored.
178    pub fn filtered_lint_rules<'a>(
179        &'a self,
180        scope: &LintScope,
181        max_level: Level,
182    ) -> FilteredLintRules<'a> {
183        FilteredLintRules::new(
184            &self.config,
185            self.initialized_lints.iter(),
186            *scope,
187            max_level,
188        )
189    }
190}
191
192/// The iterator that is returned by `LintConfiguration.initialized_lints.iter()`.
193type BTreeMapRuleIter<'a> = btree_map::Iter<'a, String, Box<dyn LintRule>>;
194
195/// An Iterator that allows iterating over lint rules filtered by a specific configuration file.
196///
197/// # Examples
198///
199/// ```
200/// use alpm_lint::{Level, LintScope, LintStore, config::LintConfiguration};
201///
202/// // Build a default config and use it to filter all lints from the store.
203/// let config = LintConfiguration::default();
204/// let store = LintStore::new(config);
205/// let mut iterator = store.filtered_lint_rules(&LintScope::SourceInfo, Level::Suggest);
206///
207/// // We get a lint
208/// assert!(iterator.next().is_some())
209/// ```
210pub struct FilteredLintRules<'a> {
211    /// The configuration used for filtering lint rules.
212    config: &'a LintConfiguration,
213    /// The unfiltered iterator over all lint rules.
214    rules_iter: BTreeMapRuleIter<'a>,
215    /// The scope in which lint rules should be.
216    scope: LintScope,
217    /// The lowest [`Level`] from which lint rules are considered.
218    min_level: Level,
219}
220
221impl<'a> FilteredLintRules<'a> {
222    /// Creates a new [`FilteredLintRules`].
223    pub fn new(
224        config: &'a LintConfiguration,
225        rules_iter: BTreeMapRuleIter<'a>,
226        scope: LintScope,
227        min_level: Level,
228    ) -> Self {
229        Self {
230            config,
231            rules_iter,
232            scope,
233            min_level,
234        }
235    }
236}
237
238impl std::fmt::Debug for FilteredLintRules<'_> {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_struct("FilteredLintRules")
241            .field("config", &self.config)
242            .field("scope", &self.scope)
243            .field("min_level", &self.min_level)
244            .finish()
245    }
246}
247
248impl<'a> Iterator for FilteredLintRules<'a> {
249    type Item = (&'a String, &'a Box<dyn LintRule>);
250
251    // Allow while_let on an iterator. This pattern is required to give us more control
252    // over `self.rules_iter`.
253    #[allow(clippy::while_let_on_iterator)]
254    fn next(&mut self) -> Option<Self::Item> {
255        'outer: while let Some((name, rule)) = self.rules_iter.next() {
256            // Check whether this rule is explicitly disabled.
257            // If so immediately skip it.
258            if self.config.disabled_rules.contains(name) {
259                continue;
260            }
261
262            // Check whether this rule is explicitly enabled.
263            // If so immediately return it.
264            if self.config.enabled_rules.contains(name) {
265                return Some((name, rule));
266            }
267
268            // Skip any lint rules that're below the specified severity level threshold.
269            // The higher the number, the less important the Level.
270            // (e.g. Error=1, Suggest=4).
271            if rule.level() as isize > self.min_level as isize {
272                continue;
273            }
274
275            // If the groups are not empty, check whether all lint groups are enabled in the
276            // configuration file. If so, the lint will be returned, otherwise skip it.
277            let groups = rule.groups();
278            if !groups.is_empty() {
279                // As there are very few groups, an `n * m` lookup is reasonable.
280                for group in groups {
281                    if !self.config.groups.contains(group) {
282                        // A group isn't enabled, skip the rule.
283                        continue 'outer;
284                    }
285                }
286            }
287
288            // Make sure that the selected scope includes this specific lint rule.
289            let lint_rule_scope = rule.scope();
290            if !self.scope.contains(&lint_rule_scope) {
291                continue;
292            }
293
294            return Some((name, rule));
295        }
296
297        None
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// Unit tests for the LintStore itself
306    mod lint_store {
307        use std::collections::HashSet;
308
309        use alpm_lint_config::{LintConfiguration, LintRuleConfiguration};
310        use testresult::TestResult;
311
312        use super::LintStore;
313
314        /// Ensures that no two lint rules have the same scoped name.
315        ///
316        /// This is extremely important as to prevent naming conflicts and to ensure that each lint
317        /// rule has a unique identifier.
318        #[test]
319        fn no_duplicate_scoped_names() {
320            let store = LintStore::new(LintConfiguration::default());
321            let config = LintRuleConfiguration::default();
322
323            // Test the raw constructors for duplicate scoped names
324            let constructors = store.lint_constructors;
325            let mut scoped_names = HashSet::<String>::new();
326
327            for constructor in constructors {
328                let lint_rule = constructor(&config);
329                let scoped_name = lint_rule.scoped_name();
330
331                if scoped_names.contains(&scoped_name) {
332                    panic!("Found duplicate scoped lint rule name: {scoped_name}");
333                }
334                scoped_names.insert(scoped_name);
335            }
336        }
337
338        /// Ensures that all lint rule names only consist of lower-case alphanumerics or
339        /// underscores.
340        #[test]
341        fn lowercase_alphanum_underscore_names() -> TestResult {
342            let store = LintStore::new(LintConfiguration::default());
343            let config = LintRuleConfiguration::default();
344
345            for constructor in store.lint_constructors {
346                let lint_rule = constructor(&config);
347                let name = lint_rule.name();
348
349                let is_valid = name
350                    .chars()
351                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
352
353                if !is_valid {
354                    let scoped_name = lint_rule.scoped_name();
355                    return Err(format!(
356                        "Found lint rule name with invalid character: '{scoped_name}'
357Lint rule names are only allowed to consist of lowercase alphanumeric characters and underscores."
358                    )
359                    .into());
360                }
361            }
362
363            Ok(())
364        }
365    }
366
367    /// Tests for the the FilteredLintRules iterator
368    mod filtered_lint_rules {
369        use std::collections::BTreeMap;
370
371        use alpm_lint_config::{LintConfiguration, LintGroup};
372
373        use super::FilteredLintRules;
374        use crate::internal_prelude::*;
375        //
376        // The iterator is explicitly tested without the store as the store will always contain all
377        // lints, meaning that the list of tested lints might change over time.
378        //
379        // To isolate things a bit and to make testing deterministic, we create some "MockLintRule"s
380        // on which we perform the filtering.
381
382        /// Test implementation of [`LintRule`] for unit testing.
383        struct MockLintRule {
384            name: &'static str,
385            scope: LintScope,
386            level: Level,
387            groups: &'static [LintGroup],
388        }
389
390        impl LintRule for MockLintRule {
391            fn name(&self) -> &'static str {
392                self.name
393            }
394
395            fn scope(&self) -> LintScope {
396                self.scope
397            }
398
399            fn level(&self) -> Level {
400                self.level
401            }
402
403            fn groups(&self) -> &'static [LintGroup] {
404                self.groups
405            }
406
407            fn run(
408                &self,
409                _resources: &Resources,
410                _issues: &mut Vec<LintIssue>,
411            ) -> Result<(), Error> {
412                Ok(())
413            }
414
415            fn documentation(&self) -> String {
416                format!("Documentation for {}", self.name)
417            }
418
419            fn help_text(&self) -> String {
420                format!("Help for {}", self.name)
421            }
422        }
423
424        impl MockLintRule {
425            /// Creates a mock lint rules.
426            fn new_boxed(name: &'static str, scope: LintScope) -> Box<dyn LintRule> {
427                Box::new(Self {
428                    name,
429                    scope,
430                    level: Level::Warn,
431                    groups: &[],
432                })
433            }
434
435            /// Creates a mock lint rule with specified groups.
436            fn with_groups(
437                name: &'static str,
438                scope: LintScope,
439                groups: &'static [LintGroup],
440            ) -> Box<dyn LintRule> {
441                Box::new(Self {
442                    name,
443                    scope,
444                    level: Level::Warn,
445                    groups,
446                })
447            }
448
449            /// Creates a mock lint rule with a specific level.
450            fn with_level(name: &'static str, scope: LintScope, level: Level) -> Box<dyn LintRule> {
451                Box::new(Self {
452                    name,
453                    scope,
454                    level,
455                    groups: &[],
456                })
457            }
458        }
459
460        /// Helper function to assert the next rule name from a filtered iterator.
461        fn next_is(filtered: &mut FilteredLintRules, expected_name: &str) {
462            let (name, _) = filtered
463                .next()
464                .unwrap_or_else(|| panic!("Should have {expected_name}"));
465            assert_eq!(name, expected_name);
466        }
467
468        /// Helper function to assert that the filtered iterator has no more rules.
469        fn next_is_none(filtered: &mut FilteredLintRules) {
470            assert!(filtered.next().is_none(), "Should have no more rules");
471        }
472
473        /// Creates a set of mock lint rules for testing with differing properties.
474        fn create_mock_rules() -> BTreeMap<String, Box<dyn LintRule>> {
475            let mut rules = BTreeMap::new();
476
477            // Always enabled for SourceInfo
478            let rule1 = MockLintRule::new_boxed("test_rule_1", LintScope::SourceInfo);
479            // Always enabled for PackageBuild
480            let rule2 = MockLintRule::new_boxed("test_rule_2", LintScope::PackageBuild);
481            // Pedantic SourceInfo Rule
482            let rule3 = MockLintRule::with_groups(
483                "pedantic_rule",
484                LintScope::SourceInfo,
485                &[LintGroup::Pedantic],
486            );
487            // Testing Group SourceInfo Rule
488            let rule4 = MockLintRule::with_groups(
489                "testing_rule",
490                LintScope::SourceInfo,
491                &[LintGroup::Testing],
492            );
493            // Pedantic **and** Testing groups SourceInfo Rule
494            let rule5 = MockLintRule::with_groups(
495                "multi_group_rule",
496                LintScope::SourceInfo,
497                &[LintGroup::Pedantic, LintGroup::Testing],
498            );
499            let rule6 = MockLintRule::with_level("with_error", LintScope::SourceInfo, Level::Error);
500
501            rules.insert(rule1.scoped_name(), rule1);
502            rules.insert(rule2.scoped_name(), rule2);
503            rules.insert(rule3.scoped_name(), rule3);
504            rules.insert(rule4.scoped_name(), rule4);
505            rules.insert(rule5.scoped_name(), rule5);
506            rules.insert(rule6.scoped_name(), rule6);
507
508            rules
509        }
510
511        /// Ensures that filtering respects scope boundaries.
512        #[test]
513        fn filters_by_scope() {
514            let config = LintConfiguration::default();
515            let rules = create_mock_rules();
516            let mut filtered = FilteredLintRules::new(
517                &config,
518                rules.iter(),
519                LintScope::SourceInfo,
520                Level::Suggest,
521            );
522
523            // Should include only ungrouped SourceInfo scope rules
524            // test_rule_1 is the only rule that's by default enabled for the SourceInfo scope.
525            next_is(&mut filtered, "source_info::test_rule_1");
526            next_is(&mut filtered, "source_info::with_error");
527            next_is_none(&mut filtered);
528        }
529
530        /// Ensures that explicitly disabled rules are excluded.
531        #[test]
532        fn respects_disabled_rules() {
533            let config = LintConfiguration {
534                disabled_rules: vec![
535                    "source_info::test_rule_1".to_string(),
536                    "source_info::with_error".to_string(),
537                ],
538                ..Default::default()
539            };
540            let rules = create_mock_rules();
541            let mut filtered = FilteredLintRules::new(
542                &config,
543                rules.iter(),
544                LintScope::SourceInfo,
545                Level::Suggest,
546            );
547
548            // Should exclude the disabled rule.
549            next_is_none(&mut filtered);
550        }
551
552        /// Ensures that explicitly enabled rules bypass group filtering.
553        #[test]
554        fn includes_explicitly_enabled_rules() {
555            let config = LintConfiguration {
556                enabled_rules: vec!["source_info::pedantic_rule".to_string()],
557                groups: vec![], // No groups enabled
558                ..Default::default()
559            };
560            let rules = create_mock_rules();
561            let mut filtered = FilteredLintRules::new(
562                &config,
563                rules.iter(),
564                LintScope::SourceInfo,
565                Level::Suggest,
566            );
567
568            // Should include the explicitly enabled pedantic rule even with no groups
569            next_is(&mut filtered, "source_info::pedantic_rule");
570            next_is(&mut filtered, "source_info::test_rule_1");
571            next_is(&mut filtered, "source_info::with_error");
572            next_is_none(&mut filtered);
573        }
574
575        /// Ensures that disabling rules takes precedence over enabling rules.
576        #[test]
577        fn disabled_rules_take_precedence() {
578            let config = LintConfiguration {
579                disabled_rules: vec![
580                    "source_info::test_rule_1".to_string(),
581                    "source_info::with_error".to_string(),
582                ],
583                enabled_rules: vec!["source_info::test_rule_1".to_string()],
584                ..Default::default()
585            };
586            let rules = create_mock_rules();
587            let mut filtered = FilteredLintRules::new(
588                &config,
589                rules.iter(),
590                LintScope::SourceInfo,
591                Level::Suggest,
592            );
593
594            // Disabled rules are checked first and take precedence
595            next_is_none(&mut filtered);
596        }
597
598        /// Ensures that rules with multiple groups require *ALL* groups to be enabled.
599        #[test]
600        fn multi_group_requires_all_groups() {
601            let config = LintConfiguration {
602                groups: vec![LintGroup::Pedantic], // Only one group enabled
603                ..Default::default()
604            };
605            let rules = create_mock_rules();
606            let mut filtered = FilteredLintRules::new(
607                &config,
608                rules.iter(),
609                LintScope::SourceInfo,
610                Level::Suggest,
611            );
612
613            // Should get pedantic_rule and test_rule_1, but not multi_group_rule
614            next_is(&mut filtered, "source_info::pedantic_rule");
615            next_is(&mut filtered, "source_info::test_rule_1");
616            next_is(&mut filtered, "source_info::with_error");
617            next_is_none(&mut filtered);
618        }
619
620        /// Ensures that multi-group lint rules are included when all their groups are enabled.
621        #[test]
622        fn multi_group_included() {
623            let config = LintConfiguration {
624                groups: vec![LintGroup::Pedantic, LintGroup::Testing],
625                ..Default::default()
626            };
627            let rules = create_mock_rules();
628            let mut filtered = FilteredLintRules::new(
629                &config,
630                rules.iter(),
631                LintScope::SourceInfo,
632                Level::Suggest,
633            );
634
635            // Should get all SourceInfo rules: multi_group_rule, pedantic_rule, test_rule_1,
636            // testing_rule
637            next_is(&mut filtered, "source_info::multi_group_rule");
638            next_is(&mut filtered, "source_info::pedantic_rule");
639            next_is(&mut filtered, "source_info::test_rule_1");
640            next_is(&mut filtered, "source_info::testing_rule");
641            next_is(&mut filtered, "source_info::with_error");
642            next_is_none(&mut filtered);
643        }
644
645        /// Ensures that the scope hierarchy is respected in filtering.
646        #[test]
647        fn source_repository_scope() {
648            let config = LintConfiguration::default();
649            let rules = create_mock_rules();
650            let mut filtered = FilteredLintRules::new(
651                &config,
652                rules.iter(),
653                LintScope::SourceRepository,
654                Level::Suggest,
655            );
656
657            // SourceRepository scope should include both SourceInfo and PackageBuild rules
658            // Both test_rule_1 and test_rule_2 are ungrouped and match the scope
659            next_is(&mut filtered, "package_build::test_rule_2");
660            next_is(&mut filtered, "source_info::test_rule_1");
661            next_is(&mut filtered, "source_info::with_error");
662            next_is_none(&mut filtered);
663        }
664
665        /// Ensures that rules are filtered by minimum level threshold.
666        #[test]
667        fn filters_by_level() {
668            let config = LintConfiguration::default();
669            let rules = create_mock_rules();
670
671            // Test with Error level threshold
672            let mut filtered =
673                FilteredLintRules::new(&config, rules.iter(), LintScope::SourceInfo, Level::Error);
674            next_is(&mut filtered, "source_info::with_error");
675            next_is_none(&mut filtered);
676        }
677    }
678}