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::package_roster::PackageRoster;
14use crate::reporting::report_printer::ReportPrinter;
15use crate::reporting::run_outcome::RunOutcome;
16use crate::reporting::scan_totals::ScanTotals;
17use crate::rule_registry::RuleRegistry;
18use crate::rules::source::header_rule::HeaderRule;
19use crate::settings::args::Args;
20use crate::settings::config::Config;
21use crate::settings::config_file::ConfigFile;
22use crate::settings::header_source::HeaderSource;
23use crate::settings::manifest_resolver::ManifestResolver;
24use crate::settings::package_config::PackageConfig;
25use crate::settings::package_sections::PackageSections;
26use crate::settings::rule_selection::RuleSelection;
27use crate::settings::scanned_package::ScannedPackage;
28use crate::source_file::SourceFile;
29use crate::source_reader::SourceReader;
30use crate::source_walker::SourceWalker;
31use crate::test_file_rewriter::TestFileRewriter;
32use anyhow::Context;
33use anyhow::Result;
34use std::collections::HashSet;
35use std::fs::write as write_file;
36use std::path::Path;
37use std::path::PathBuf;
38
39pub struct Runner;
55
56impl Runner {
57 pub fn run(args: Args) -> Result<RunOutcome> {
58 let sections = PackageSections::load(&Self::manifest_directory(&args.manifest_path))?;
59 let config = Self::config_from(&args, None)?;
60 Self::validate_selection(&config)?;
61 let config = Config {
62 workspace_dependencies: ManifestResolver::workspace_dependencies(&config),
63 ..config
64 };
65 let packages = ManifestResolver::packages(&config)?;
66 let workspace = ManifestResolver::workspace_package_names(&config)?;
67 sections.validate(&workspace.iter().map(String::as_str).collect::<Vec<_>>())?;
68 let config = Config {
76 manifest_license: ScannedPackage::agreed_license(&packages),
77 selection: config.selection.also_skipping(
78 §ions.skipped_anywhere(
79 &packages
80 .iter()
81 .map(|package| package.name.as_str())
82 .collect::<Vec<_>>(),
83 ),
84 ),
85 ..config
86 };
87 let registry = RuleRegistry::from_config(&config);
88 if registry.is_empty() {
89 return Err(anyhow::anyhow!(
90 "no rules are configured, so nothing would be checked -- pass --header-file to \
91 enable the header rule"
92 ));
93 }
94
95 let mut offences = Vec::new();
96 let mut files_scanned = 0usize;
97 let mut excluded = Vec::new();
98 let mut fixed = 0usize;
99 let mut rosters: Vec<PackageRoster> = Vec::new();
100
101 for package in &packages {
102 let package_config = Config {
105 manifest_license: package.license.clone(),
106 workspace_dependencies: config.workspace_dependencies.clone(),
107 ..Self::config_from(&args, sections.of(&package.name))?
108 };
109 let registry = RuleRegistry::from_config(&package_config);
110 rosters.push(PackageRoster::new(
111 &package.name,
112 Self::owned(®istry.names()),
113 Self::owned(&RuleRegistry::skipped_names(&package_config.selection)),
114 registry
115 .unconfigured(&package_config)
116 .into_iter()
117 .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
118 .collect(),
119 ));
120 let exclusions = ExclusionSet::new(&package_config.excludes)?;
121 let root = &package.root;
122 let outcome = exclusions.apply(SourceWalker::walk(root), root);
127 let mut files: Vec<SourceFile> = Vec::new();
128 for path in outcome.kept {
129 files_scanned += 1;
130 match SourceReader::read(root, &path) {
131 Ok(file) => files.push(file),
132 Err(offence) => offences.push(*offence),
133 }
134 }
135 if config.fix {
136 let (rewritten, count) = Self::repair(root, files)?;
137 files = rewritten;
138 fixed += count;
139 }
140 for file in &files {
141 offences.extend(registry.check(file));
142 }
143 offences.extend(registry.check_workspace(&files));
144 excluded.push(outcome.excluded);
145 }
146
147 let mut seen = HashSet::new();
160 offences.retain(|offence| seen.insert(offence.clone()));
161 offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
162
163 if config.write_baseline {
164 return Self::record(&config, offences);
165 }
166 let baselined = Self::baselined(&config, offences)?;
167 let offences = baselined.kept;
168 Self::report(
169 &config,
170 ®istry,
171 ScanTotals::new(files_scanned, fixed),
172 &Self::merged(excluded),
173 &BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
174 &rosters,
175 &offences,
176 );
177 Ok(RunOutcome::of(offences.len()))
178 }
179
180 fn validate_selection(config: &Config) -> Result<()> {
185 let known = RuleRegistry::known_names();
186 let unknown = config.selection.unknown_in(&known);
187 if !unknown.is_empty() {
188 return Err(anyhow::anyhow!(
189 "unknown rule name(s): {} -- the rules are: {}",
190 unknown.join(", "),
191 known.join(", ")
192 ));
193 }
194 if config.selection.selects_explicitly(HeaderRule::NAME)
195 && config.expected_header.is_empty()
196 {
197 return Err(anyhow::anyhow!(
198 "--rule {} needs --header-file, otherwise the run would apply no rules at all",
199 HeaderRule::NAME
200 ));
201 }
202 Ok(())
203 }
204
205 fn config_from(args: &Args, section: Option<&PackageConfig>) -> Result<Config> {
214 let directory = Self::manifest_directory(&args.manifest_path);
215 let file = ConfigFile::load(&directory)?;
216 let found = file.as_ref();
217 let header_file = args
218 .header_file
219 .clone()
220 .or_else(|| section.and_then(|s| s.header_file_from(&directory)))
221 .or_else(|| found.and_then(|file| file.header_file_from(&directory)));
222 let expected_header = match &header_file {
223 Some(path) => HeaderSource::read(path)?,
224 None => Vec::new(),
225 };
226 let threshold = args
227 .offence_threshold
228 .or_else(|| found.and_then(|file| file.offence_threshold))
229 .unwrap_or(OffenceThreshold::DEFAULT);
230 Ok(Config {
231 manifest_license: None,
234 workspace_dependencies: None,
235 baseline: args
241 .baseline
242 .clone()
243 .or_else(|| found.and_then(|file| file.baseline_from(&directory)))
244 .or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
245 write_baseline: args.write_baseline,
246 fix: args.fix,
247 config_file: found.map(|_| directory.join(ConfigFile::NAME)),
248 manifest_path: args.manifest_path.clone(),
249 max_files_per_directory: section
250 .and_then(|s| s.max_files_per_directory)
251 .or_else(|| found.and_then(|file| file.max_files_per_directory)),
252 max_subfolders_per_directory: section
253 .and_then(|s| s.max_subfolders_per_directory)
254 .or_else(|| found.and_then(|file| file.max_subfolders_per_directory)),
255 packages: args.packages.clone(),
256 excludes: Self::preferred(
257 args.excludes.clone(),
258 Self::section_or_root(section.map(|s| &s.exclude), found.map(|file| &file.exclude)),
259 ),
260 expected_header,
261 format: args.format,
262 offence_threshold: OffenceThreshold::new(threshold),
263 selection: RuleSelection::new(
264 Self::preferred(
265 args.rules.clone(),
266 Self::section_or_root(section.map(|s| &s.rules), found.map(|file| &file.rules)),
267 ),
268 Self::preferred(
269 args.skipped_rules.clone(),
270 Self::section_or_root(section.map(|s| &s.skip), found.map(|file| &file.skip)),
271 ),
272 ),
273 })
274 }
275
276 fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
280 let path = directory.join(Self::BASELINE_NAME);
281 (writing || path.exists()).then_some(path)
282 }
283
284 fn section_or_root<'a>(
288 section: Option<&'a Vec<String>>,
289 root: Option<&'a Vec<String>>,
290 ) -> Option<&'a Vec<String>> {
291 match section {
292 Some(values) if !values.is_empty() => Some(values),
293 _ => root,
294 }
295 }
296
297 fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
298 if !from_args.is_empty() {
299 return from_args;
300 }
301 from_file.cloned().unwrap_or_default()
302 }
303
304 fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
307 manifest_path
308 .as_ref()
309 .and_then(|path| path.parent())
310 .map(Path::to_path_buf)
311 .unwrap_or_else(|| PathBuf::from("."))
312 }
313
314 pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
315
316 fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
322 let mut repaired = Vec::with_capacity(files.len());
323 let mut count = 0;
324 for file in files {
325 match TestFileRewriter::rewrite(&file) {
326 Some(contents) => {
327 let path = root.join(file.relative_path());
328 write_file(&path, &contents)
329 .with_context(|| format!("{} could not be rewritten", path.display()))?;
330 repaired.push(SourceFile::new(file.relative_path(), &contents));
331 count += 1;
332 }
333 None => repaired.push(file),
334 }
335 }
336 Ok((repaired, count))
337 }
338
339 fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
342 let path = config
343 .baseline
344 .as_ref()
345 .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
346 let baseline = Baseline::of(&offences);
347 baseline.save(path)?;
348 println!(
349 "stern4rust wrote {} offence(s) to {}",
350 baseline.len(),
351 path.display()
352 );
353 Ok(RunOutcome::Clean)
354 }
355
356 fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
360 let Some(path) = &config.baseline else {
361 return Ok(BaselineOutcome::new(offences, 0, 0));
362 };
363 Ok(Baseline::load(path)?.apply(offences))
364 }
365
366 fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
371 let mut totals: Vec<(String, usize)> = Vec::new();
372 for counts in per_root {
373 for (pattern, count) in counts {
374 match totals.iter_mut().find(|(known, _)| *known == pattern) {
375 Some(entry) => entry.1 += count,
376 None => totals.push((pattern, count)),
377 }
378 }
379 }
380 ExclusionOutcome::new(Vec::new(), totals)
381 }
382
383 fn report(
384 config: &Config,
385 registry: &RuleRegistry,
386 totals: ScanTotals,
387 excluded: &ExclusionOutcome,
388 baselined: &BaselineOutcome,
389 rosters: &[PackageRoster],
390 offences: &[Offence],
391 ) {
392 let threshold = config.offence_threshold;
393 let applied = Self::owned(®istry.names());
394 let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
395 let unconfigured: Vec<(String, String)> = registry
396 .unconfigured(config)
397 .into_iter()
398 .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
399 .collect();
400 let unconfigured_names = Self::owned(®istry.unconfigured_names(config));
401 match config.format {
402 OutputFormat::Text => ReportPrinter::new(totals.files_scanned)
403 .with_threshold(threshold)
404 .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
405 .with_package_rosters(rosters.to_vec())
406 .with_exclusions(excluded.excluded.clone())
407 .with_config_file(Self::shown(config))
408 .with_baseline(
409 Self::baseline_shown(config),
410 baselined.suppressed,
411 baselined.stale,
412 )
413 .with_fixed(totals.fixed)
414 .print(offences),
415 OutputFormat::Json => JsonPrinter::new(totals.files_scanned)
416 .with_threshold(threshold)
417 .with_rules(applied, skipped, unconfigured_names)
418 .with_exclusions(excluded.excluded.clone())
419 .with_config_file(Self::shown(config))
420 .with_baseline(
421 Self::baseline_shown(config),
422 baselined.suppressed,
423 baselined.stale,
424 )
425 .with_fixed(totals.fixed)
426 .print(offences),
427 }
428 }
429
430 fn baseline_shown(config: &Config) -> Option<String> {
431 config
432 .baseline
433 .as_ref()
434 .map(|path| path.to_string_lossy().replace('\\', "/"))
435 }
436
437 fn shown(config: &Config) -> Option<String> {
438 config
439 .config_file
440 .as_ref()
441 .map(|path| path.to_string_lossy().replace('\\', "/"))
442 }
443
444 fn owned(names: &[&str]) -> Vec<String> {
445 names.iter().map(|name| (*name).to_string()).collect()
446 }
447}