1use 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
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 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 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 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 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 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 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 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(®istry.names());
297 let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
298 let unconfigured = Self::owned(®istry.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}