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 anyhow::Result;
6
7use crate::args::Args;
8use crate::config::Config;
9use crate::header_source::HeaderSource;
10use crate::json_printer::JsonPrinter;
11use crate::manifest_resolver::ManifestResolver;
12use crate::offence::Offence;
13use crate::offence_threshold::OffenceThreshold;
14use crate::output_format::OutputFormat;
15use crate::report_printer::ReportPrinter;
16use crate::rule_registry::RuleRegistry;
17use crate::run_outcome::RunOutcome;
18use crate::source_reader::SourceReader;
19use crate::source_walker::SourceWalker;
20
21// Exit codes are the whole contract with a gate script:
22//
23//   0  every rule satisfied
24//   1  could not run -- returned as an Err and turned into 1 by main
25//   2  at least one rule broken
26//
27// 2 is kept distinct from 1 on purpose. A script that treats every non-zero code
28// alike cannot tell "your code has a problem" from "I could not read your code",
29// and the second one silently passing is how a gate stops meaning anything.
30//
31// The line between the two is what can still be enumerated. A bad manifest or an
32// unknown package is a 1: without it there is no list of files to judge. A single
33// unreadable file is a 2, reported against readable-source like any other
34// finding -- it is a fact about the tree, and aborting on it would hide every
35// offence already found in every other file.
36pub struct Runner;
37
38impl Runner {
39    pub fn run(args: Args) -> Result<RunOutcome> {
40        let config = Self::config_from(args)?;
41        let registry = RuleRegistry::from_config(&config);
42        if registry.is_empty() {
43            return Err(anyhow::anyhow!(
44                "no rules are configured, so nothing would be checked -- pass --header-file to \
45                 enable the header rule"
46            ));
47        }
48
49        let roots = ManifestResolver::package_roots(&config)?;
50        let mut offences = Vec::new();
51        let mut files_scanned = 0usize;
52
53        for root in &roots {
54            // Read the whole package before judging it. Rules whose subject is
55            // the tree -- "there is exactly one all_tests.rs" -- cannot be
56            // answered a file at a time, and the file that carries the offence
57            // is often the one that does not exist.
58            let mut files = Vec::new();
59            for path in SourceWalker::walk(root) {
60                files_scanned += 1;
61                match SourceReader::read(root, &path) {
62                    Ok(file) => files.push(file),
63                    Err(offence) => offences.push(*offence),
64                }
65            }
66            for file in &files {
67                offences.extend(registry.check(file));
68            }
69            offences.extend(registry.check_workspace(&files));
70        }
71
72        // Rules run in registration order and the tree-wide pass runs last, so
73        // without this the report jumps between files. Sorting is the report's
74        // business rather than any rule's -- a rule states facts, and their
75        // order on the page is not one of them.
76        offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
77        Self::report(&config, files_scanned, &offences);
78        Ok(RunOutcome::of(offences.len()))
79    }
80
81    fn config_from(args: Args) -> Result<Config> {
82        let expected_header = match &args.header_file {
83            Some(path) => HeaderSource::read(path)?,
84            None => Vec::new(),
85        };
86        Ok(Config {
87            manifest_path: args.manifest_path,
88            packages: args.packages,
89            expected_header,
90            format: args.format,
91            offence_threshold: OffenceThreshold::new(args.offence_threshold),
92        })
93    }
94
95    fn report(config: &Config, files_scanned: usize, offences: &[Offence]) {
96        let threshold = config.offence_threshold;
97        match config.format {
98            OutputFormat::Text => ReportPrinter::new(files_scanned)
99                .with_threshold(threshold)
100                .print(offences),
101            OutputFormat::Json => JsonPrinter::new(files_scanned)
102                .with_threshold(threshold)
103                .print(offences),
104        }
105    }
106}