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::args::Args;
6use crate::baseline::Baseline;
7use crate::baseline_outcome::BaselineOutcome;
8use crate::config::Config;
9use crate::config_file::ConfigFile;
10use crate::exclusion_outcome::ExclusionOutcome;
11use crate::exclusion_set::ExclusionSet;
12use crate::header_source::HeaderSource;
13use crate::json_printer::JsonPrinter;
14use crate::manifest_resolver::ManifestResolver;
15use crate::offence::Offence;
16use crate::offence_threshold::OffenceThreshold;
17use crate::output_format::OutputFormat;
18use crate::report_printer::ReportPrinter;
19use crate::rule_registry::RuleRegistry;
20use crate::rule_selection::RuleSelection;
21use crate::rules::header_rule::HeaderRule;
22use crate::run_outcome::RunOutcome;
23use crate::source_file::SourceFile;
24use crate::source_reader::SourceReader;
25use crate::source_walker::SourceWalker;
26use crate::test_file_rewriter::TestFileRewriter;
27use anyhow::Context;
28use anyhow::Result;
29use std::fs::write as write_file;
30use std::path::Path;
31use std::path::PathBuf;
32
33// Exit codes are the whole contract with a gate script:
34//
35//   0  every rule satisfied
36//   1  could not run -- returned as an Err and turned into 1 by main
37//   2  at least one rule broken
38//
39// 2 is kept distinct from 1 on purpose. A script that treats every non-zero code
40// alike cannot tell "your code has a problem" from "I could not read your code",
41// and the second one silently passing is how a gate stops meaning anything.
42//
43// The line between the two is what can still be enumerated. A bad manifest or an
44// unknown package is a 1: without it there is no list of files to judge. A single
45// unreadable file is a 2, reported against readable-source like any other
46// finding -- it is a fact about the tree, and aborting on it would hide every
47// offence already found in every other file.
48pub struct Runner;
49
50impl Runner {
51    pub fn run(args: Args) -> Result<RunOutcome> {
52        let config = Self::config_from(args)?;
53        Self::validate_selection(&config)?;
54        let registry = RuleRegistry::from_config(&config);
55        if registry.is_empty() {
56            return Err(anyhow::anyhow!(
57                "no rules are configured, so nothing would be checked -- pass --header-file to \
58                 enable the header rule"
59            ));
60        }
61
62        let roots = ManifestResolver::package_roots(&config)?;
63        let exclusions = ExclusionSet::new(&config.excludes)?;
64        let mut offences = Vec::new();
65        let mut files_scanned = 0usize;
66        let mut excluded = Vec::new();
67        let mut fixed = 0usize;
68
69        for root in &roots {
70            // Read the whole package before judging it. Rules whose subject is
71            // the tree -- "there is exactly one all_tests.rs" -- cannot be
72            // answered a file at a time, and the file that carries the offence
73            // is often the one that does not exist.
74            let outcome = exclusions.apply(SourceWalker::walk(root), root);
75            let mut files: Vec<SourceFile> = Vec::new();
76            for path in outcome.kept {
77                files_scanned += 1;
78                match SourceReader::read(root, &path) {
79                    Ok(file) => files.push(file),
80                    Err(offence) => offences.push(*offence),
81                }
82            }
83            if config.fix {
84                let (rewritten, count) = Self::repair(root, files)?;
85                files = rewritten;
86                fixed += count;
87            }
88            for file in &files {
89                offences.extend(registry.check(file));
90            }
91            offences.extend(registry.check_workspace(&files));
92            excluded.push(outcome.excluded);
93        }
94
95        // Rules run in registration order and the tree-wide pass runs last, so
96        // without this the report jumps between files. Sorting is the report's
97        // business rather than any rule's -- a rule states facts, and their
98        // order on the page is not one of them.
99        offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
100
101        if config.write_baseline {
102            return Self::record(&config, offences);
103        }
104        let baselined = Self::baselined(&config, offences)?;
105        let offences = baselined.kept;
106        Self::report(
107            &config,
108            &registry,
109            files_scanned,
110            &Self::merged(excluded),
111            &BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
112            fixed,
113            &offences,
114        );
115        Ok(RunOutcome::of(offences.len()))
116    }
117
118    // A misspelled rule name is an error rather than a switch that quietly
119    // matches nothing, and asking for the header rule without a header file is
120    // an error rather than an empty run. Both would otherwise look exactly like
121    // a run that worked.
122    fn validate_selection(config: &Config) -> Result<()> {
123        let known = RuleRegistry::known_names();
124        let unknown = config.selection.unknown_in(&known);
125        if !unknown.is_empty() {
126            return Err(anyhow::anyhow!(
127                "unknown rule name(s): {} -- the rules are: {}",
128                unknown.join(", "),
129                known.join(", ")
130            ));
131        }
132        if config.selection.selects_explicitly(HeaderRule::NAME)
133            && config.expected_header.is_empty()
134        {
135            return Err(anyhow::anyhow!(
136                "--rule {} needs --header-file, otherwise the run would apply no rules at all",
137                HeaderRule::NAME
138            ));
139        }
140        Ok(())
141    }
142
143    // The command line wins over the file, every time and per setting. A
144    // repository states its defaults in stern4rust.toml; a person overrides one
145    // of them for one run without having to restate the rest.
146    //
147    // "Wins" is replacement rather than merging for the list settings. Merging
148    // would make `--rule header` mean "header plus whatever the file already
149    // selected", which is the opposite of what naming one rule means everywhere
150    // else in this tool.
151    fn config_from(args: Args) -> Result<Config> {
152        let directory = Self::manifest_directory(&args.manifest_path);
153        let file = ConfigFile::load(&directory)?;
154        let found = file.as_ref();
155        let header_file = args
156            .header_file
157            .or_else(|| found.and_then(|file| file.header_file_from(&directory)));
158        let expected_header = match &header_file {
159            Some(path) => HeaderSource::read(path)?,
160            None => Vec::new(),
161        };
162        let threshold = args
163            .offence_threshold
164            .or_else(|| found.and_then(|file| file.offence_threshold))
165            .unwrap_or(OffenceThreshold::DEFAULT);
166        Ok(Config {
167            // Discovered beside the manifest when nobody named one, the same
168            // way stern4rust.toml is. Implicit suppression would be
169            // unacceptable if it were invisible; every report that used a
170            // baseline names it and states how many offences it hid, so a
171            // reader can always see that one is in force.
172            baseline: args
173                .baseline
174                .or_else(|| found.and_then(|file| file.baseline_from(&directory)))
175                .or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
176            write_baseline: args.write_baseline,
177            fix: args.fix,
178            config_file: found.map(|_| directory.join(ConfigFile::NAME)),
179            manifest_path: args.manifest_path,
180            packages: args.packages,
181            excludes: Self::preferred(args.excludes, found.map(|file| &file.exclude)),
182            expected_header,
183            format: args.format,
184            offence_threshold: OffenceThreshold::new(threshold),
185            selection: RuleSelection::new(
186                Self::preferred(args.rules, found.map(|file| &file.rules)),
187                Self::preferred(args.skipped_rules, found.map(|file| &file.skip)),
188            ),
189        })
190    }
191
192    // When writing, the default path is the destination whether or not it
193    // exists yet. When reading, only an existing file counts -- otherwise every
194    // run without a baseline would fail trying to load one.
195    fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
196        let path = directory.join(Self::BASELINE_NAME);
197        (writing || path.exists()).then_some(path)
198    }
199
200    fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
201        if !from_args.is_empty() {
202            return from_args;
203        }
204        from_file.cloned().unwrap_or_default()
205    }
206
207    // The config lives beside the manifest it configures, so a workspace and a
208    // package in it can hold different ones.
209    fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
210        manifest_path
211            .as_ref()
212            .and_then(|path| path.parent())
213            .map(Path::to_path_buf)
214            .unwrap_or_else(|| PathBuf::from("."))
215    }
216
217    pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
218
219    // Rewrites what can be rewritten and hands back the files as they now are,
220    // so the checks that follow judge the repaired tree. Whatever is still
221    // wrong is reported exactly as it would have been without --fix -- a fixer
222    // that quietly swallowed what it could not fix would be worse than no fixer
223    // at all.
224    fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
225        let mut repaired = Vec::with_capacity(files.len());
226        let mut count = 0;
227        for file in files {
228            match TestFileRewriter::rewrite(&file) {
229                Some(contents) => {
230                    let path = root.join(file.relative_path());
231                    write_file(&path, &contents)
232                        .with_context(|| format!("{} could not be rewritten", path.display()))?;
233                    repaired.push(SourceFile::new(file.relative_path(), &contents));
234                    count += 1;
235                }
236                None => repaired.push(file),
237            }
238        }
239        Ok((repaired, count))
240    }
241
242    // Recording is not judging. The run exits clean because nothing was
243    // assessed -- the offences were written down, which is what was asked for.
244    fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
245        let path = config
246            .baseline
247            .as_ref()
248            .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
249        let baseline = Baseline::of(&offences);
250        baseline.save(path)?;
251        println!(
252            "stern4rust wrote {} offence(s) to {}",
253            baseline.len(),
254            path.display()
255        );
256        Ok(RunOutcome::Clean)
257    }
258
259    // A baseline that was asked for and is not there is an error rather than an
260    // empty one. A gate whose baseline path has a typo would otherwise report
261    // every existing offence and look like a regression.
262    fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
263        let Some(path) = &config.baseline else {
264            return Ok(BaselineOutcome::new(offences, 0, 0));
265        };
266        Ok(Baseline::load(path)?.apply(offences))
267    }
268
269    // One package's exclusions say nothing on their own: a pattern matching
270    // nothing in package A and forty files in package B has done its job, and
271    // reporting it as dead for A would be a wrong answer rather than a missing
272    // one. So the counts are summed across roots before anybody looks at them.
273    fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
274        let mut totals: Vec<(String, usize)> = Vec::new();
275        for counts in per_root {
276            for (pattern, count) in counts {
277                match totals.iter_mut().find(|(known, _)| *known == pattern) {
278                    Some(entry) => entry.1 += count,
279                    None => totals.push((pattern, count)),
280                }
281            }
282        }
283        ExclusionOutcome::new(Vec::new(), totals)
284    }
285
286    fn report(
287        config: &Config,
288        registry: &RuleRegistry,
289        files_scanned: usize,
290        excluded: &ExclusionOutcome,
291        baselined: &BaselineOutcome,
292        fixed: usize,
293        offences: &[Offence],
294    ) {
295        let threshold = config.offence_threshold;
296        let applied = Self::owned(&registry.names());
297        let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
298        let unconfigured = Self::owned(&registry.unconfigured_names(config));
299        match config.format {
300            OutputFormat::Text => ReportPrinter::new(files_scanned)
301                .with_threshold(threshold)
302                .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
303                .with_exclusions(excluded.excluded.clone())
304                .with_config_file(Self::shown(config))
305                .with_baseline(
306                    Self::baseline_shown(config),
307                    baselined.suppressed,
308                    baselined.stale,
309                )
310                .with_fixed(fixed)
311                .print(offences),
312            OutputFormat::Json => JsonPrinter::new(files_scanned)
313                .with_threshold(threshold)
314                .with_rules(applied, skipped, unconfigured)
315                .with_exclusions(excluded.excluded.clone())
316                .with_config_file(Self::shown(config))
317                .with_baseline(
318                    Self::baseline_shown(config),
319                    baselined.suppressed,
320                    baselined.stale,
321                )
322                .with_fixed(fixed)
323                .print(offences),
324        }
325    }
326
327    fn baseline_shown(config: &Config) -> Option<String> {
328        config
329            .baseline
330            .as_ref()
331            .map(|path| path.to_string_lossy().replace('\\', "/"))
332    }
333
334    fn shown(config: &Config) -> Option<String> {
335        config
336            .config_file
337            .as_ref()
338            .map(|path| path.to_string_lossy().replace('\\', "/"))
339    }
340
341    fn owned(names: &[&str]) -> Vec<String> {
342        names.iter().map(|name| (*name).to_string()).collect()
343    }
344}