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