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::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
41pub 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 pub fn run_reporting(args: Args) -> Result<(RunOutcome, String)> {
71 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 let config = Config {
95 manifest_license: ScannedPackage::agreed_license(&packages),
96 selection: config.selection.also_skipping(
97 §ions.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 let workspace_root = ManifestResolver::workspace_root(&config).unwrap_or_default();
122
123 for package in &packages {
124 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(®istry.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 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 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 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 ®istry,
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 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 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 manifest_license: None,
286 workspace_dependencies: None,
287 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 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(®istry.explanations()).render(args.format)
344 }
345
346 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 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 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 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 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 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 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(®istry.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(®istry.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}