1use 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::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::fs::write as write_file;
30use std::path::Path;
31use std::path::PathBuf;
32
33pub 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 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 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 ®istry,
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 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 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 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 max_files_per_directory: found.and_then(|file| file.max_files_per_directory),
181 max_subfolders_per_directory: found.and_then(|file| file.max_subfolders_per_directory),
182 packages: args.packages,
183 excludes: Self::preferred(args.excludes, found.map(|file| &file.exclude)),
184 expected_header,
185 format: args.format,
186 offence_threshold: OffenceThreshold::new(threshold),
187 selection: RuleSelection::new(
188 Self::preferred(args.rules, found.map(|file| &file.rules)),
189 Self::preferred(args.skipped_rules, found.map(|file| &file.skip)),
190 ),
191 })
192 }
193
194 fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
198 let path = directory.join(Self::BASELINE_NAME);
199 (writing || path.exists()).then_some(path)
200 }
201
202 fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
203 if !from_args.is_empty() {
204 return from_args;
205 }
206 from_file.cloned().unwrap_or_default()
207 }
208
209 fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
212 manifest_path
213 .as_ref()
214 .and_then(|path| path.parent())
215 .map(Path::to_path_buf)
216 .unwrap_or_else(|| PathBuf::from("."))
217 }
218
219 pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
220
221 fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
227 let mut repaired = Vec::with_capacity(files.len());
228 let mut count = 0;
229 for file in files {
230 match TestFileRewriter::rewrite(&file) {
231 Some(contents) => {
232 let path = root.join(file.relative_path());
233 write_file(&path, &contents)
234 .with_context(|| format!("{} could not be rewritten", path.display()))?;
235 repaired.push(SourceFile::new(file.relative_path(), &contents));
236 count += 1;
237 }
238 None => repaired.push(file),
239 }
240 }
241 Ok((repaired, count))
242 }
243
244 fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
247 let path = config
248 .baseline
249 .as_ref()
250 .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
251 let baseline = Baseline::of(&offences);
252 baseline.save(path)?;
253 println!(
254 "stern4rust wrote {} offence(s) to {}",
255 baseline.len(),
256 path.display()
257 );
258 Ok(RunOutcome::Clean)
259 }
260
261 fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
265 let Some(path) = &config.baseline else {
266 return Ok(BaselineOutcome::new(offences, 0, 0));
267 };
268 Ok(Baseline::load(path)?.apply(offences))
269 }
270
271 fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
276 let mut totals: Vec<(String, usize)> = Vec::new();
277 for counts in per_root {
278 for (pattern, count) in counts {
279 match totals.iter_mut().find(|(known, _)| *known == pattern) {
280 Some(entry) => entry.1 += count,
281 None => totals.push((pattern, count)),
282 }
283 }
284 }
285 ExclusionOutcome::new(Vec::new(), totals)
286 }
287
288 fn report(
289 config: &Config,
290 registry: &RuleRegistry,
291 files_scanned: usize,
292 excluded: &ExclusionOutcome,
293 baselined: &BaselineOutcome,
294 fixed: usize,
295 offences: &[Offence],
296 ) {
297 let threshold = config.offence_threshold;
298 let applied = Self::owned(®istry.names());
299 let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
300 let unconfigured = Self::owned(®istry.unconfigured_names(config));
301 match config.format {
302 OutputFormat::Text => ReportPrinter::new(files_scanned)
303 .with_threshold(threshold)
304 .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
305 .with_exclusions(excluded.excluded.clone())
306 .with_config_file(Self::shown(config))
307 .with_baseline(
308 Self::baseline_shown(config),
309 baselined.suppressed,
310 baselined.stale,
311 )
312 .with_fixed(fixed)
313 .print(offences),
314 OutputFormat::Json => JsonPrinter::new(files_scanned)
315 .with_threshold(threshold)
316 .with_rules(applied, skipped, unconfigured)
317 .with_exclusions(excluded.excluded.clone())
318 .with_config_file(Self::shown(config))
319 .with_baseline(
320 Self::baseline_shown(config),
321 baselined.suppressed,
322 baselined.stale,
323 )
324 .with_fixed(fixed)
325 .print(offences),
326 }
327 }
328
329 fn baseline_shown(config: &Config) -> Option<String> {
330 config
331 .baseline
332 .as_ref()
333 .map(|path| path.to_string_lossy().replace('\\', "/"))
334 }
335
336 fn shown(config: &Config) -> Option<String> {
337 config
338 .config_file
339 .as_ref()
340 .map(|path| path.to_string_lossy().replace('\\', "/"))
341 }
342
343 fn owned(names: &[&str]) -> Vec<String> {
344 names.iter().map(|name| (*name).to_string()).collect()
345 }
346}