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::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
34pub 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 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 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 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 ®istry,
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 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 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 manifest_license: None,
188 workspace_dependencies: None,
189 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 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 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 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 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 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 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(®istry.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(®istry.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}