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::rule_listing::RuleListing;
16use crate::reporting::run_outcome::RunOutcome;
17use crate::reporting::scan_totals::ScanTotals;
18use crate::rule_registry::RuleRegistry;
19use crate::rules::source::header_rule::HeaderRule;
20use crate::settings::args::Args;
21use crate::settings::config::Config;
22use crate::settings::config_file::ConfigFile;
23use crate::settings::header_source::HeaderSource;
24use crate::settings::manifest_resolver::ManifestResolver;
25use crate::settings::package_config::PackageConfig;
26use crate::settings::package_sections::PackageSections;
27use crate::settings::rule_selection::RuleSelection;
28use crate::settings::scanned_package::ScannedPackage;
29use crate::source_file::SourceFile;
30use crate::source_reader::SourceReader;
31use crate::source_walker::SourceWalker;
32use crate::test_file_rewriter::TestFileRewriter;
33use anyhow::Context;
34use anyhow::Result;
35use std::collections::HashSet;
36use std::fs::write as write_file;
37use std::path::Path;
38use std::path::PathBuf;
39
40pub struct Runner;
56
57impl Runner {
58 pub fn run(args: Args) -> Result<RunOutcome> {
59 let (outcome, report) = Self::run_reporting(args)?;
60 println!("{report}");
61 Ok(outcome)
62 }
63
64 pub fn run_reporting(args: Args) -> Result<(RunOutcome, String)> {
70 if args.list_rules {
74 return Ok((RunOutcome::Clean, Self::rule_listing(&args)));
75 }
76 let sections = PackageSections::load(&Self::manifest_directory(&args.manifest_path))?;
77 let config = Self::config_from(&args, None)?;
78 Self::validate_selection(&config)?;
79 let config = Config {
80 workspace_dependencies: ManifestResolver::workspace_dependencies(&config),
81 ..config
82 };
83 let packages = ManifestResolver::packages(&config)?;
84 let workspace = ManifestResolver::workspace_package_names(&config)?;
85 sections.validate(&workspace.iter().map(String::as_str).collect::<Vec<_>>())?;
86 let config = Config {
94 manifest_license: ScannedPackage::agreed_license(&packages),
95 selection: config.selection.also_skipping(
96 §ions.skipped_anywhere(
97 &packages
98 .iter()
99 .map(|package| package.name.as_str())
100 .collect::<Vec<_>>(),
101 ),
102 ),
103 ..config
104 };
105 let registry = RuleRegistry::from_config(&config);
106 if registry.is_empty() {
107 return Err(anyhow::anyhow!(
108 "no rules are configured, so nothing would be checked -- pass --header-file to \
109 enable the header rule"
110 ));
111 }
112
113 let mut offences = Vec::new();
114 let mut files_scanned = 0usize;
115 let mut excluded = Vec::new();
116 let mut fixed = 0usize;
117 let mut rosters: Vec<PackageRoster> = Vec::new();
118
119 for package in &packages {
120 let package_config = Config {
123 manifest_license: package.license.clone(),
124 workspace_dependencies: config.workspace_dependencies.clone(),
125 ..Self::config_from(&args, sections.of(&package.name))?
126 };
127 let registry = RuleRegistry::from_config(&package_config);
128 rosters.push(PackageRoster::new(
129 &package.name,
130 Self::owned(®istry.names()),
131 Self::owned(&RuleRegistry::skipped_names(&package_config.selection)),
132 registry
133 .unconfigured(&package_config)
134 .into_iter()
135 .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
136 .collect(),
137 ));
138 let exclusions = ExclusionSet::new(&package_config.excludes)?;
139 let root = &package.root;
140 let outcome = exclusions.apply(SourceWalker::walk(root), root);
145 let mut files: Vec<SourceFile> = Vec::new();
146 let mut found: Vec<Offence> = Vec::new();
147 for path in outcome.kept {
148 files_scanned += 1;
149 match SourceReader::read(root, &path) {
150 Ok(file) => files.push(file),
151 Err(offence) => found.push(*offence),
152 }
153 }
154 if config.fix {
155 let (rewritten, count) = Self::repair(root, files)?;
156 files = rewritten;
157 fixed += count;
158 }
159 for file in &files {
160 found.extend(registry.check(file));
161 }
162 found.extend(registry.check_workspace(&files));
163
164 let mut seen = HashSet::new();
181 found.retain(|offence| seen.insert(offence.clone()));
182 offences.extend(found);
183 excluded.push(outcome.excluded);
184 }
185
186 offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
191
192 if config.write_baseline {
193 return Self::record(&config, offences).map(|outcome| (outcome, String::new()));
194 }
195 let baselined = Self::baselined(&config, offences)?;
196 let offences = baselined.kept;
197 let report = Self::report(
198 &config,
199 ®istry,
200 ScanTotals::new(files_scanned, fixed),
201 &Self::merged(excluded),
202 &BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
203 &rosters,
204 &offences,
205 );
206 Ok((RunOutcome::of(offences.len()), report))
207 }
208
209 fn validate_selection(config: &Config) -> Result<()> {
214 let known = RuleRegistry::known_names();
215 let unknown = config.selection.unknown_in(&known);
216 if !unknown.is_empty() {
217 return Err(anyhow::anyhow!(
218 "unknown rule name(s): {} -- the rules are: {}",
219 unknown.join(", "),
220 known.join(", ")
221 ));
222 }
223 if config.selection.selects_explicitly(HeaderRule::NAME)
224 && config.expected_header.is_empty()
225 {
226 return Err(anyhow::anyhow!(
227 "--rule {} needs --header-file, otherwise the run would apply no rules at all",
228 HeaderRule::NAME
229 ));
230 }
231 Ok(())
232 }
233
234 fn config_from(args: &Args, section: Option<&PackageConfig>) -> Result<Config> {
243 let directory = Self::manifest_directory(&args.manifest_path);
244 let file = ConfigFile::load(&directory)?;
245 let found = file.as_ref();
246 let header_file = args
247 .header_file
248 .clone()
249 .or_else(|| section.and_then(|s| s.header_file_from(&directory)))
250 .or_else(|| found.and_then(|file| file.header_file_from(&directory)));
251 let expected_header = match &header_file {
252 Some(path) => HeaderSource::read(path)?,
253 None => Vec::new(),
254 };
255 let threshold = args
256 .offence_threshold
257 .or_else(|| found.and_then(|file| file.offence_threshold))
258 .unwrap_or(OffenceThreshold::DEFAULT);
259 Ok(Config {
260 manifest_license: None,
263 workspace_dependencies: None,
264 baseline: args
270 .baseline
271 .clone()
272 .or_else(|| found.and_then(|file| file.baseline_from(&directory)))
273 .or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
274 write_baseline: args.write_baseline,
275 fix: args.fix,
276 config_file: found.map(|_| directory.join(ConfigFile::NAME)),
277 manifest_path: args.manifest_path.clone(),
278 max_files_per_directory: section
279 .and_then(|s| s.max_files_per_directory)
280 .or_else(|| found.and_then(|file| file.max_files_per_directory)),
281 max_subfolders_per_directory: section
282 .and_then(|s| s.max_subfolders_per_directory)
283 .or_else(|| found.and_then(|file| file.max_subfolders_per_directory)),
284 packages: args.packages.clone(),
285 excludes: Self::preferred(
286 args.excludes.clone(),
287 Self::section_or_root(section.map(|s| &s.exclude), found.map(|file| &file.exclude)),
288 ),
289 expected_header,
290 format: args.format,
291 offence_threshold: OffenceThreshold::new(threshold),
292 selection: RuleSelection::new(
293 Self::preferred(
294 args.rules.clone(),
295 Self::section_or_root(section.map(|s| &s.rules), found.map(|file| &file.rules)),
296 ),
297 Self::preferred(
298 args.skipped_rules.clone(),
299 Self::section_or_root(section.map(|s| &s.skip), found.map(|file| &file.skip)),
300 ),
301 ),
302 })
303 }
304
305 fn rule_listing(args: &Args) -> String {
315 let registry = RuleRegistry::from_config(&Config {
316 expected_header: vec![String::new()],
317 manifest_license: Some(String::new()),
318 ..Config::default()
319 });
320 RuleListing::new(®istry.explanations()).render(args.format)
321 }
322
323 fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
327 let path = directory.join(Self::BASELINE_NAME);
328 (writing || path.exists()).then_some(path)
329 }
330
331 fn section_or_root<'a>(
335 section: Option<&'a Vec<String>>,
336 root: Option<&'a Vec<String>>,
337 ) -> Option<&'a Vec<String>> {
338 match section {
339 Some(values) if !values.is_empty() => Some(values),
340 _ => root,
341 }
342 }
343
344 fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
345 if !from_args.is_empty() {
346 return from_args;
347 }
348 from_file.cloned().unwrap_or_default()
349 }
350
351 fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
354 manifest_path
355 .as_ref()
356 .and_then(|path| path.parent())
357 .map(Path::to_path_buf)
358 .unwrap_or_else(|| PathBuf::from("."))
359 }
360
361 pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
362
363 fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
369 let mut repaired = Vec::with_capacity(files.len());
370 let mut count = 0;
371 for file in files {
372 match TestFileRewriter::rewrite(&file) {
373 Some(contents) => {
374 let path = root.join(file.relative_path());
375 write_file(&path, &contents)
376 .with_context(|| format!("{} could not be rewritten", path.display()))?;
377 repaired.push(SourceFile::new(file.relative_path(), &contents));
378 count += 1;
379 }
380 None => repaired.push(file),
381 }
382 }
383 Ok((repaired, count))
384 }
385
386 fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
389 let path = config
390 .baseline
391 .as_ref()
392 .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
393 let baseline = Baseline::of(&offences);
394 baseline.save(path)?;
395 println!(
396 "stern4rust wrote {} offence(s) to {}",
397 baseline.len(),
398 path.display()
399 );
400 Ok(RunOutcome::Clean)
401 }
402
403 fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
407 let Some(path) = &config.baseline else {
408 return Ok(BaselineOutcome::new(offences, 0, 0));
409 };
410 Ok(Baseline::load(path)?.apply(offences))
411 }
412
413 fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
418 let mut totals: Vec<(String, usize)> = Vec::new();
419 for counts in per_root {
420 for (pattern, count) in counts {
421 match totals.iter_mut().find(|(known, _)| *known == pattern) {
422 Some(entry) => entry.1 += count,
423 None => totals.push((pattern, count)),
424 }
425 }
426 }
427 ExclusionOutcome::new(Vec::new(), totals)
428 }
429
430 fn report(
431 config: &Config,
432 registry: &RuleRegistry,
433 totals: ScanTotals,
434 excluded: &ExclusionOutcome,
435 baselined: &BaselineOutcome,
436 rosters: &[PackageRoster],
437 offences: &[Offence],
438 ) -> String {
439 let threshold = config.offence_threshold;
440 let applied = Self::owned(®istry.names());
441 let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
442 let unconfigured: Vec<(String, String)> = registry
443 .unconfigured(config)
444 .into_iter()
445 .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
446 .collect();
447 let unconfigured_names = Self::owned(®istry.unconfigured_names(config));
448 match config.format {
449 OutputFormat::Text => ReportPrinter::new(totals.files_scanned)
450 .with_threshold(threshold)
451 .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
452 .with_package_rosters(rosters.to_vec())
453 .with_exclusions(excluded.excluded.clone())
454 .with_config_file(Self::shown(config))
455 .with_baseline(
456 Self::baseline_shown(config),
457 baselined.suppressed,
458 baselined.stale,
459 )
460 .with_fixed(totals.fixed)
461 .render(offences),
462 OutputFormat::Json => JsonPrinter::new(totals.files_scanned)
463 .with_threshold(threshold)
464 .with_rules(applied, skipped, unconfigured_names)
465 .with_package_rosters(rosters.to_vec())
466 .with_exclusions(excluded.excluded.clone())
467 .with_config_file(Self::shown(config))
468 .with_baseline(
469 Self::baseline_shown(config),
470 baselined.suppressed,
471 baselined.stale,
472 )
473 .with_fixed(totals.fixed)
474 .render(offences),
475 }
476 }
477
478 fn baseline_shown(config: &Config) -> Option<String> {
479 config
480 .baseline
481 .as_ref()
482 .map(|path| path.to_string_lossy().replace('\\', "/"))
483 }
484
485 fn shown(config: &Config) -> Option<String> {
486 config
487 .config_file
488 .as_ref()
489 .map(|path| path.to_string_lossy().replace('\\', "/"))
490 }
491
492 fn owned(names: &[&str]) -> Vec<String> {
493 names.iter().map(|name| (*name).to_string()).collect()
494 }
495}