Skip to main content

stern4rust/
runner.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::adoption::baseline::Baseline;
6use crate::adoption::baseline_outcome::BaselineOutcome;
7use crate::adoption::exclusion_outcome::ExclusionOutcome;
8use crate::adoption::exclusion_set::ExclusionSet;
9use crate::reporting::json_printer::JsonPrinter;
10use crate::reporting::offence::Offence;
11use crate::reporting::offence_threshold::OffenceThreshold;
12use crate::reporting::output_format::OutputFormat;
13use crate::reporting::package_roster::PackageRoster;
14use crate::reporting::report_printer::ReportPrinter;
15use crate::reporting::run_outcome::RunOutcome;
16use crate::reporting::scan_totals::ScanTotals;
17use crate::rule_registry::RuleRegistry;
18use crate::rules::source::header_rule::HeaderRule;
19use crate::settings::args::Args;
20use crate::settings::config::Config;
21use crate::settings::config_file::ConfigFile;
22use crate::settings::header_source::HeaderSource;
23use crate::settings::manifest_resolver::ManifestResolver;
24use crate::settings::package_config::PackageConfig;
25use crate::settings::package_sections::PackageSections;
26use crate::settings::rule_selection::RuleSelection;
27use crate::settings::scanned_package::ScannedPackage;
28use crate::source_file::SourceFile;
29use crate::source_reader::SourceReader;
30use crate::source_walker::SourceWalker;
31use crate::test_file_rewriter::TestFileRewriter;
32use anyhow::Context;
33use anyhow::Result;
34use std::collections::HashSet;
35use std::fs::write as write_file;
36use std::path::Path;
37use std::path::PathBuf;
38
39// Exit codes are the whole contract with a gate script:
40//
41//   0  every rule satisfied
42//   1  could not run -- returned as an Err and turned into 1 by main
43//   2  at least one rule broken
44//
45// 2 is kept distinct from 1 on purpose. A script that treats every non-zero code
46// alike cannot tell "your code has a problem" from "I could not read your code",
47// and the second one silently passing is how a gate stops meaning anything.
48//
49// The line between the two is what can still be enumerated. A bad manifest or an
50// unknown package is a 1: without it there is no list of files to judge. A single
51// unreadable file is a 2, reported against readable-source like any other
52// finding -- it is a fact about the tree, and aborting on it would hide every
53// offence already found in every other file.
54pub struct Runner;
55
56impl Runner {
57    pub fn run(args: Args) -> Result<RunOutcome> {
58        let sections = PackageSections::load(&Self::manifest_directory(&args.manifest_path))?;
59        let config = Self::config_from(&args, None)?;
60        Self::validate_selection(&config)?;
61        let config = Config {
62            workspace_dependencies: ManifestResolver::workspace_dependencies(&config),
63            ..config
64        };
65        let packages = ManifestResolver::packages(&config)?;
66        let workspace = ManifestResolver::workspace_package_names(&config)?;
67        sections.validate(&workspace.iter().map(String::as_str).collect::<Vec<_>>())?;
68        // What the report answers for. A rule that stood down for any package
69        // did not apply to this run, so the licence stated here is the one every
70        // scanned package agrees on and nothing otherwise. Checking is per
71        // package; only the summary is aggregate, and it understates rather than
72        // overstates -- see
73        // [ADR-PerPackageConfiguration](../docs/ADRs/ADR-PerPackageConfiguration.md),
74        // where the per-package report is the piece still to come.
75        let config = Config {
76            manifest_license: ScannedPackage::agreed_license(&packages),
77            selection: config.selection.also_skipping(
78                &sections.skipped_anywhere(
79                    &packages
80                        .iter()
81                        .map(|package| package.name.as_str())
82                        .collect::<Vec<_>>(),
83                ),
84            ),
85            ..config
86        };
87        let registry = RuleRegistry::from_config(&config);
88        if registry.is_empty() {
89            return Err(anyhow::anyhow!(
90                "no rules are configured, so nothing would be checked -- pass --header-file to \
91                 enable the header rule"
92            ));
93        }
94
95        let mut offences = Vec::new();
96        let mut files_scanned = 0usize;
97        let mut excluded = Vec::new();
98        let mut fixed = 0usize;
99        let mut rosters: Vec<PackageRoster> = Vec::new();
100
101        for package in &packages {
102            // Everything the manifest decides is decided here, by the package
103            // about to be walked, rather than once for the run.
104            let package_config = Config {
105                manifest_license: package.license.clone(),
106                workspace_dependencies: config.workspace_dependencies.clone(),
107                ..Self::config_from(&args, sections.of(&package.name))?
108            };
109            let registry = RuleRegistry::from_config(&package_config);
110            rosters.push(PackageRoster::new(
111                &package.name,
112                Self::owned(&registry.names()),
113                Self::owned(&RuleRegistry::skipped_names(&package_config.selection)),
114                registry
115                    .unconfigured(&package_config)
116                    .into_iter()
117                    .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
118                    .collect(),
119            ));
120            let exclusions = ExclusionSet::new(&package_config.excludes)?;
121            let root = &package.root;
122            // Read the whole package before judging it. Rules whose subject is
123            // the tree -- "there is exactly one all_tests.rs" -- cannot be
124            // answered a file at a time, and the file that carries the offence
125            // is often the one that does not exist.
126            let outcome = exclusions.apply(SourceWalker::walk(root), root);
127            let mut files: Vec<SourceFile> = Vec::new();
128            for path in outcome.kept {
129                files_scanned += 1;
130                match SourceReader::read(root, &path) {
131                    Ok(file) => files.push(file),
132                    Err(offence) => offences.push(*offence),
133                }
134            }
135            if config.fix {
136                let (rewritten, count) = Self::repair(root, files)?;
137                files = rewritten;
138                fixed += count;
139            }
140            for file in &files {
141                offences.extend(registry.check(file));
142            }
143            offences.extend(registry.check_workspace(&files));
144            excluded.push(outcome.excluded);
145        }
146
147        // Rules run in registration order and the tree-wide pass runs last, so
148        // without this the report jumps between files. Sorting is the report's
149        // business rather than any rule's -- a rule states facts, and their
150        // order on the page is not one of them.
151        // The workspace question is asked once per package root, so a rule whose
152        // subject is the *workspace* rather than the package -- the manifest
153        // rules -- states the same finding once per member. The same sentence
154        // about the same line of the same file is one finding, not several.
155        //
156        // By content rather than by `dedup`, because two findings about one
157        // manifest interleave once sorted and consecutive-only removal misses
158        // every copy after the first pair.
159        let mut seen = HashSet::new();
160        offences.retain(|offence| seen.insert(offence.clone()));
161        offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
162
163        if config.write_baseline {
164            return Self::record(&config, offences);
165        }
166        let baselined = Self::baselined(&config, offences)?;
167        let offences = baselined.kept;
168        Self::report(
169            &config,
170            &registry,
171            ScanTotals::new(files_scanned, fixed),
172            &Self::merged(excluded),
173            &BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
174            &rosters,
175            &offences,
176        );
177        Ok(RunOutcome::of(offences.len()))
178    }
179
180    // A misspelled rule name is an error rather than a switch that quietly
181    // matches nothing, and asking for the header rule without a header file is
182    // an error rather than an empty run. Both would otherwise look exactly like
183    // a run that worked.
184    fn validate_selection(config: &Config) -> Result<()> {
185        let known = RuleRegistry::known_names();
186        let unknown = config.selection.unknown_in(&known);
187        if !unknown.is_empty() {
188            return Err(anyhow::anyhow!(
189                "unknown rule name(s): {} -- the rules are: {}",
190                unknown.join(", "),
191                known.join(", ")
192            ));
193        }
194        if config.selection.selects_explicitly(HeaderRule::NAME)
195            && config.expected_header.is_empty()
196        {
197            return Err(anyhow::anyhow!(
198                "--rule {} needs --header-file, otherwise the run would apply no rules at all",
199                HeaderRule::NAME
200            ));
201        }
202        Ok(())
203    }
204
205    // The command line wins over the file, every time and per setting. A
206    // repository states its defaults in stern4rust.toml; a person overrides one
207    // of them for one run without having to restate the rest.
208    //
209    // "Wins" is replacement rather than merging for the list settings. Merging
210    // would make `--rule header` mean "header plus whatever the file already
211    // selected", which is the opposite of what naming one rule means everywhere
212    // else in this tool.
213    fn config_from(args: &Args, section: Option<&PackageConfig>) -> Result<Config> {
214        let directory = Self::manifest_directory(&args.manifest_path);
215        let file = ConfigFile::load(&directory)?;
216        let found = file.as_ref();
217        let header_file = args
218            .header_file
219            .clone()
220            .or_else(|| section.and_then(|s| s.header_file_from(&directory)))
221            .or_else(|| found.and_then(|file| file.header_file_from(&directory)));
222        let expected_header = match &header_file {
223            Some(path) => HeaderSource::read(path)?,
224            None => Vec::new(),
225        };
226        let threshold = args
227            .offence_threshold
228            .or_else(|| found.and_then(|file| file.offence_threshold))
229            .unwrap_or(OffenceThreshold::DEFAULT);
230        Ok(Config {
231            // Filled in by `run` once the manifest has been read; the command
232            // line has nothing to say about it.
233            manifest_license: None,
234            workspace_dependencies: None,
235            // Discovered beside the manifest when nobody named one, the same
236            // way stern4rust.toml is. Implicit suppression would be
237            // unacceptable if it were invisible; every report that used a
238            // baseline names it and states how many offences it hid, so a
239            // reader can always see that one is in force.
240            baseline: args
241                .baseline
242                .clone()
243                .or_else(|| found.and_then(|file| file.baseline_from(&directory)))
244                .or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
245            write_baseline: args.write_baseline,
246            fix: args.fix,
247            config_file: found.map(|_| directory.join(ConfigFile::NAME)),
248            manifest_path: args.manifest_path.clone(),
249            max_files_per_directory: section
250                .and_then(|s| s.max_files_per_directory)
251                .or_else(|| found.and_then(|file| file.max_files_per_directory)),
252            max_subfolders_per_directory: section
253                .and_then(|s| s.max_subfolders_per_directory)
254                .or_else(|| found.and_then(|file| file.max_subfolders_per_directory)),
255            packages: args.packages.clone(),
256            excludes: Self::preferred(
257                args.excludes.clone(),
258                Self::section_or_root(section.map(|s| &s.exclude), found.map(|file| &file.exclude)),
259            ),
260            expected_header,
261            format: args.format,
262            offence_threshold: OffenceThreshold::new(threshold),
263            selection: RuleSelection::new(
264                Self::preferred(
265                    args.rules.clone(),
266                    Self::section_or_root(section.map(|s| &s.rules), found.map(|file| &file.rules)),
267                ),
268                Self::preferred(
269                    args.skipped_rules.clone(),
270                    Self::section_or_root(section.map(|s| &s.skip), found.map(|file| &file.skip)),
271                ),
272            ),
273        })
274    }
275
276    // When writing, the default path is the destination whether or not it
277    // exists yet. When reading, only an existing file counts -- otherwise every
278    // run without a baseline would fail trying to load one.
279    fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
280        let path = directory.join(Self::BASELINE_NAME);
281        (writing || path.exists()).then_some(path)
282    }
283
284    // A section states its whole list rather than adding to the root's, which is
285    // the argument already made for the command line against the file, one level
286    // down: a reader who wants to know what a package skips reads one list.
287    fn section_or_root<'a>(
288        section: Option<&'a Vec<String>>,
289        root: Option<&'a Vec<String>>,
290    ) -> Option<&'a Vec<String>> {
291        match section {
292            Some(values) if !values.is_empty() => Some(values),
293            _ => root,
294        }
295    }
296
297    fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
298        if !from_args.is_empty() {
299            return from_args;
300        }
301        from_file.cloned().unwrap_or_default()
302    }
303
304    // The config lives beside the manifest it configures, so a workspace and a
305    // package in it can hold different ones.
306    fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
307        manifest_path
308            .as_ref()
309            .and_then(|path| path.parent())
310            .map(Path::to_path_buf)
311            .unwrap_or_else(|| PathBuf::from("."))
312    }
313
314    pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
315
316    // Rewrites what can be rewritten and hands back the files as they now are,
317    // so the checks that follow judge the repaired tree. Whatever is still
318    // wrong is reported exactly as it would have been without --fix -- a fixer
319    // that quietly swallowed what it could not fix would be worse than no fixer
320    // at all.
321    fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
322        let mut repaired = Vec::with_capacity(files.len());
323        let mut count = 0;
324        for file in files {
325            match TestFileRewriter::rewrite(&file) {
326                Some(contents) => {
327                    let path = root.join(file.relative_path());
328                    write_file(&path, &contents)
329                        .with_context(|| format!("{} could not be rewritten", path.display()))?;
330                    repaired.push(SourceFile::new(file.relative_path(), &contents));
331                    count += 1;
332                }
333                None => repaired.push(file),
334            }
335        }
336        Ok((repaired, count))
337    }
338
339    // Recording is not judging. The run exits clean because nothing was
340    // assessed -- the offences were written down, which is what was asked for.
341    fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
342        let path = config
343            .baseline
344            .as_ref()
345            .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
346        let baseline = Baseline::of(&offences);
347        baseline.save(path)?;
348        println!(
349            "stern4rust wrote {} offence(s) to {}",
350            baseline.len(),
351            path.display()
352        );
353        Ok(RunOutcome::Clean)
354    }
355
356    // A baseline that was asked for and is not there is an error rather than an
357    // empty one. A gate whose baseline path has a typo would otherwise report
358    // every existing offence and look like a regression.
359    fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
360        let Some(path) = &config.baseline else {
361            return Ok(BaselineOutcome::new(offences, 0, 0));
362        };
363        Ok(Baseline::load(path)?.apply(offences))
364    }
365
366    // One package's exclusions say nothing on their own: a pattern matching
367    // nothing in package A and forty files in package B has done its job, and
368    // reporting it as dead for A would be a wrong answer rather than a missing
369    // one. So the counts are summed across roots before anybody looks at them.
370    fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
371        let mut totals: Vec<(String, usize)> = Vec::new();
372        for counts in per_root {
373            for (pattern, count) in counts {
374                match totals.iter_mut().find(|(known, _)| *known == pattern) {
375                    Some(entry) => entry.1 += count,
376                    None => totals.push((pattern, count)),
377                }
378            }
379        }
380        ExclusionOutcome::new(Vec::new(), totals)
381    }
382
383    fn report(
384        config: &Config,
385        registry: &RuleRegistry,
386        totals: ScanTotals,
387        excluded: &ExclusionOutcome,
388        baselined: &BaselineOutcome,
389        rosters: &[PackageRoster],
390        offences: &[Offence],
391    ) {
392        let threshold = config.offence_threshold;
393        let applied = Self::owned(&registry.names());
394        let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
395        let unconfigured: Vec<(String, String)> = registry
396            .unconfigured(config)
397            .into_iter()
398            .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
399            .collect();
400        let unconfigured_names = Self::owned(&registry.unconfigured_names(config));
401        match config.format {
402            OutputFormat::Text => ReportPrinter::new(totals.files_scanned)
403                .with_threshold(threshold)
404                .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
405                .with_package_rosters(rosters.to_vec())
406                .with_exclusions(excluded.excluded.clone())
407                .with_config_file(Self::shown(config))
408                .with_baseline(
409                    Self::baseline_shown(config),
410                    baselined.suppressed,
411                    baselined.stale,
412                )
413                .with_fixed(totals.fixed)
414                .print(offences),
415            OutputFormat::Json => JsonPrinter::new(totals.files_scanned)
416                .with_threshold(threshold)
417                .with_rules(applied, skipped, unconfigured_names)
418                .with_exclusions(excluded.excluded.clone())
419                .with_config_file(Self::shown(config))
420                .with_baseline(
421                    Self::baseline_shown(config),
422                    baselined.suppressed,
423                    baselined.stale,
424                )
425                .with_fixed(totals.fixed)
426                .print(offences),
427        }
428    }
429
430    fn baseline_shown(config: &Config) -> Option<String> {
431        config
432            .baseline
433            .as_ref()
434            .map(|path| path.to_string_lossy().replace('\\', "/"))
435    }
436
437    fn shown(config: &Config) -> Option<String> {
438        config
439            .config_file
440            .as_ref()
441            .map(|path| path.to_string_lossy().replace('\\', "/"))
442    }
443
444    fn owned(names: &[&str]) -> Vec<String> {
445        names.iter().map(|name| (*name).to_string()).collect()
446    }
447}