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