Skip to main content

stern4rust/settings/
package_sections.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use anyhow::Result;
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use crate::settings::config_file::ConfigFile;
10use crate::settings::package_config::PackageConfig;
11use crate::settings::scanned_package::ScannedPackage;
12
13// The `[package.<name>]` sections of the root `stern4rust.toml`, and the two
14// questions asked of them: which section applies to the package about to be
15// walked, and does every section name a package this run actually scans.
16//
17// A type rather than a pair of functions on the runner, because the second
18// question only means anything beside the first: a section is either the rule
19// set for a member or a name that matches nothing, and nothing else.
20//
21// See [ADR-PerPackageConfiguration](../../docs/ADRs/ADR-PerPackageConfiguration.md).
22pub struct PackageSections {
23    sections: BTreeMap<String, PackageConfig>,
24}
25
26impl PackageSections {
27    pub fn new(sections: BTreeMap<String, PackageConfig>) -> Self {
28        Self { sections }
29    }
30
31    // No file and a file with no sections are the same answer here. They differ
32    // only for `ConfigFile::load`, which has to tell a missing file from an
33    // unreadable one.
34    pub fn load(directory: &Path) -> Result<Self> {
35        Ok(Self::new(
36            ConfigFile::load(directory)?
37                .map(|file| file.packages)
38                .unwrap_or_default(),
39        ))
40    }
41
42    pub fn of(&self, name: &str) -> Option<&PackageConfig> {
43        self.sections.get(name)
44    }
45
46    // Every rule any section stands down on.
47    //
48    // The report answers for the run as a whole, and a rule that did not apply
49    // to one package did not apply to the run. Reporting it as applied would be
50    // the overstatement this tool exists to refuse: a stand-down is only
51    // acceptable while the report names it. Until the report speaks per package,
52    // this is what keeps it honest -- it understates, naming a rule as skipped
53    // even where most packages applied it.
54    pub fn skipped_anywhere(&self) -> Vec<String> {
55        let mut skipped: Vec<String> = self
56            .sections
57            .values()
58            .flat_map(|section| section.skip.iter().cloned())
59            .collect();
60        skipped.sort();
61        skipped.dedup();
62        skipped
63    }
64
65    pub fn is_empty(&self) -> bool {
66        self.sections.is_empty()
67    }
68
69    // A section naming no package this run scans is an error, for the reason a
70    // misspelled `--rule` name is: it reads as a rule set being applied.
71    // `deny_unknown_fields` cannot catch it, because the section name is data
72    // rather than a key.
73    pub fn validate(&self, packages: &[ScannedPackage]) -> Result<()> {
74        let scanned: Vec<&str> = packages
75            .iter()
76            .map(|package| package.name.as_str())
77            .collect();
78        let unknown: Vec<&str> = self
79            .sections
80            .keys()
81            .map(String::as_str)
82            .filter(|name| !scanned.contains(name))
83            .collect();
84        if unknown.is_empty() {
85            return Ok(());
86        }
87        Err(anyhow::anyhow!(
88            "{} configures package(s) this run does not scan: {} -- it scans: {}",
89            ConfigFile::NAME,
90            unknown.join(", "),
91            scanned.join(", ")
92        ))
93    }
94}