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::rule_selection::RuleSelection;
18use crate::rules::header_rule::HeaderRule;
19use crate::run_outcome::RunOutcome;
20use crate::source_reader::SourceReader;
21use crate::source_walker::SourceWalker;
22
23// Exit codes are the whole contract with a gate script:
24//
25//   0  every rule satisfied
26//   1  could not run -- returned as an Err and turned into 1 by main
27//   2  at least one rule broken
28//
29// 2 is kept distinct from 1 on purpose. A script that treats every non-zero code
30// alike cannot tell "your code has a problem" from "I could not read your code",
31// and the second one silently passing is how a gate stops meaning anything.
32//
33// The line between the two is what can still be enumerated. A bad manifest or an
34// unknown package is a 1: without it there is no list of files to judge. A single
35// unreadable file is a 2, reported against readable-source like any other
36// finding -- it is a fact about the tree, and aborting on it would hide every
37// offence already found in every other file.
38pub struct Runner;
39
40impl Runner {
41    pub fn run(args: Args) -> Result<RunOutcome> {
42        let config = Self::config_from(args)?;
43        Self::validate_selection(&config)?;
44        let registry = RuleRegistry::from_config(&config);
45        if registry.is_empty() {
46            return Err(anyhow::anyhow!(
47                "no rules are configured, so nothing would be checked -- pass --header-file to \
48                 enable the header rule"
49            ));
50        }
51
52        let roots = ManifestResolver::package_roots(&config)?;
53        let mut offences = Vec::new();
54        let mut files_scanned = 0usize;
55
56        for root in &roots {
57            // Read the whole package before judging it. Rules whose subject is
58            // the tree -- "there is exactly one all_tests.rs" -- cannot be
59            // answered a file at a time, and the file that carries the offence
60            // is often the one that does not exist.
61            let mut files = Vec::new();
62            for path in SourceWalker::walk(root) {
63                files_scanned += 1;
64                match SourceReader::read(root, &path) {
65                    Ok(file) => files.push(file),
66                    Err(offence) => offences.push(*offence),
67                }
68            }
69            for file in &files {
70                offences.extend(registry.check(file));
71            }
72            offences.extend(registry.check_workspace(&files));
73        }
74
75        // Rules run in registration order and the tree-wide pass runs last, so
76        // without this the report jumps between files. Sorting is the report's
77        // business rather than any rule's -- a rule states facts, and their
78        // order on the page is not one of them.
79        offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
80        Self::report(&config, &registry, files_scanned, &offences);
81        Ok(RunOutcome::of(offences.len()))
82    }
83
84    // A misspelled rule name is an error rather than a switch that quietly
85    // matches nothing, and asking for the header rule without a header file is
86    // an error rather than an empty run. Both would otherwise look exactly like
87    // a run that worked.
88    fn validate_selection(config: &Config) -> Result<()> {
89        let known = RuleRegistry::known_names();
90        let unknown = config.selection.unknown_in(&known);
91        if !unknown.is_empty() {
92            return Err(anyhow::anyhow!(
93                "unknown rule name(s): {} -- the rules are: {}",
94                unknown.join(", "),
95                known.join(", ")
96            ));
97        }
98        if config.selection.selects_explicitly(HeaderRule::NAME)
99            && config.expected_header.is_empty()
100        {
101            return Err(anyhow::anyhow!(
102                "--rule {} needs --header-file, otherwise the run would apply no rules at all",
103                HeaderRule::NAME
104            ));
105        }
106        Ok(())
107    }
108
109    fn config_from(args: Args) -> Result<Config> {
110        let expected_header = match &args.header_file {
111            Some(path) => HeaderSource::read(path)?,
112            None => Vec::new(),
113        };
114        Ok(Config {
115            manifest_path: args.manifest_path,
116            packages: args.packages,
117            expected_header,
118            format: args.format,
119            offence_threshold: OffenceThreshold::new(args.offence_threshold),
120            selection: RuleSelection::new(args.rules, args.skipped_rules),
121        })
122    }
123
124    fn report(
125        config: &Config,
126        registry: &RuleRegistry,
127        files_scanned: usize,
128        offences: &[Offence],
129    ) {
130        let threshold = config.offence_threshold;
131        let applied = Self::owned(&registry.names());
132        let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
133        let unconfigured = Self::owned(&registry.unconfigured_names(config));
134        match config.format {
135            OutputFormat::Text => ReportPrinter::new(files_scanned)
136                .with_threshold(threshold)
137                .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
138                .print(offences),
139            OutputFormat::Json => JsonPrinter::new(files_scanned)
140                .with_threshold(threshold)
141                .with_rules(applied, skipped, unconfigured)
142                .print(offences),
143        }
144    }
145
146    fn owned(names: &[&str]) -> Vec<String> {
147        names.iter().map(|name| (*name).to_string()).collect()
148    }
149}