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;
11
12// The `[package.<name>]` sections of the root `stern4rust.toml`, and the two
13// questions asked of them: which section applies to the package about to be
14// walked, and does every section name a package this run actually scans.
15//
16// A type rather than a pair of functions on the runner, because the second
17// question only means anything beside the first: a section is either the rule
18// set for a member or a name that matches nothing, and nothing else.
19//
20// See [ADR-PerPackageConfiguration](../../docs/ADRs/ADR-PerPackageConfiguration.md).
21pub struct PackageSections {
22 sections: BTreeMap<String, PackageConfig>,
23}
24
25impl PackageSections {
26 pub fn new(sections: BTreeMap<String, PackageConfig>) -> Self {
27 Self { sections }
28 }
29
30 // No file and a file with no sections are the same answer here. They differ
31 // only for `ConfigFile::load`, which has to tell a missing file from an
32 // unreadable one.
33 pub fn load(directory: &Path) -> Result<Self> {
34 Ok(Self::new(
35 ConfigFile::load(directory)?
36 .map(|file| file.packages)
37 .unwrap_or_default(),
38 ))
39 }
40
41 pub fn of(&self, name: &str) -> Option<&PackageConfig> {
42 self.sections.get(name)
43 }
44
45 // Every rule any section stands down on.
46 //
47 // The report answers for the run as a whole, and a rule that did not apply
48 // to one package did not apply to the run. Reporting it as applied would be
49 // the overstatement this tool exists to refuse: a stand-down is only
50 // acceptable while the report names it. Until the report speaks per package,
51 // this is what keeps it honest -- it understates, naming a rule as skipped
52 // even where most packages applied it.
53 //
54 // Only the packages this run walks. Counting a section for one it does not
55 // made a scoped run contradict itself: the roster listed a rule as applied
56 // while the summary beneath it called the same rule skipped.
57 pub fn skipped_anywhere(&self, scanned: &[&str]) -> Vec<String> {
58 let mut skipped: Vec<String> = self
59 .sections
60 .iter()
61 .filter(|(name, _)| scanned.contains(&name.as_str()))
62 .flat_map(|(_, section)| section.skip.iter().cloned())
63 .collect();
64 skipped.sort();
65 skipped.dedup();
66 skipped
67 }
68
69 pub fn is_empty(&self) -> bool {
70 self.sections.is_empty()
71 }
72
73 // A section naming no package in the *workspace* is an error, for the reason
74 // a misspelled `--rule` name is: it reads as a rule set being applied, and
75 // `deny_unknown_fields` cannot catch it because the section name is data
76 // rather than a key.
77 //
78 // The workspace, not the scan. Scoping a run to one package is an ordinary
79 // thing to do, and the sections for the others are not typos -- checking
80 // against the scan made `--package node` an error in any repository whose
81 // root config had sections, which is every repository this exists for.
82 pub fn validate(&self, workspace: &[&str]) -> Result<()> {
83 let unknown: Vec<&str> = self
84 .sections
85 .keys()
86 .map(String::as_str)
87 .filter(|name| !workspace.contains(name))
88 .collect();
89 if unknown.is_empty() {
90 return Ok(());
91 }
92 Err(anyhow::anyhow!(
93 "{} configures package(s) that are not in this workspace: {} -- it holds: {}",
94 ConfigFile::NAME,
95 unknown.join(", "),
96 workspace.join(", ")
97 ))
98 }
99}