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 for path in outcome.kept {
147 files_scanned += 1;
148 match SourceReader::read(root, &path) {
149 Ok(file) => files.push(file),
150 Err(offence) => offences.push(*offence),
151 }
152 }
153 if config.fix {
154 let (rewritten, count) = Self::repair(root, files)?;
155 files = rewritten;
156 fixed += count;
157 }
158 for file in &files {
159 offences.extend(registry.check(file));
160 }
161 offences.extend(registry.check_workspace(&files));
162 excluded.push(outcome.excluded);
163 }
164
165 let mut seen = HashSet::new();
178 offences.retain(|offence| seen.insert(offence.clone()));
179 offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
180
181 if config.write_baseline {
182 return Self::record(&config, offences).map(|outcome| (outcome, String::new()));
183 }
184 let baselined = Self::baselined(&config, offences)?;
185 let offences = baselined.kept;
186 let report = Self::report(
187 &config,
188 ®istry,
189 ScanTotals::new(files_scanned, fixed),
190 &Self::merged(excluded),
191 &BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
192 &rosters,
193 &offences,
194 );
195 Ok((RunOutcome::of(offences.len()), report))
196 }
197
198 fn validate_selection(config: &Config) -> Result<()> {
203 let known = RuleRegistry::known_names();
204 let unknown = config.selection.unknown_in(&known);
205 if !unknown.is_empty() {
206 return Err(anyhow::anyhow!(
207 "unknown rule name(s): {} -- the rules are: {}",
208 unknown.join(", "),
209 known.join(", ")
210 ));
211 }
212 if config.selection.selects_explicitly(HeaderRule::NAME)
213 && config.expected_header.is_empty()
214 {
215 return Err(anyhow::anyhow!(
216 "--rule {} needs --header-file, otherwise the run would apply no rules at all",
217 HeaderRule::NAME
218 ));
219 }
220 Ok(())
221 }
222
223 fn config_from(args: &Args, section: Option<&PackageConfig>) -> Result<Config> {
232 let directory = Self::manifest_directory(&args.manifest_path);
233 let file = ConfigFile::load(&directory)?;
234 let found = file.as_ref();
235 let header_file = args
236 .header_file
237 .clone()
238 .or_else(|| section.and_then(|s| s.header_file_from(&directory)))
239 .or_else(|| found.and_then(|file| file.header_file_from(&directory)));
240 let expected_header = match &header_file {
241 Some(path) => HeaderSource::read(path)?,
242 None => Vec::new(),
243 };
244 let threshold = args
245 .offence_threshold
246 .or_else(|| found.and_then(|file| file.offence_threshold))
247 .unwrap_or(OffenceThreshold::DEFAULT);
248 Ok(Config {
249 manifest_license: None,
252 workspace_dependencies: None,
253 baseline: args
259 .baseline
260 .clone()
261 .or_else(|| found.and_then(|file| file.baseline_from(&directory)))
262 .or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
263 write_baseline: args.write_baseline,
264 fix: args.fix,
265 config_file: found.map(|_| directory.join(ConfigFile::NAME)),
266 manifest_path: args.manifest_path.clone(),
267 max_files_per_directory: section
268 .and_then(|s| s.max_files_per_directory)
269 .or_else(|| found.and_then(|file| file.max_files_per_directory)),
270 max_subfolders_per_directory: section
271 .and_then(|s| s.max_subfolders_per_directory)
272 .or_else(|| found.and_then(|file| file.max_subfolders_per_directory)),
273 packages: args.packages.clone(),
274 excludes: Self::preferred(
275 args.excludes.clone(),
276 Self::section_or_root(section.map(|s| &s.exclude), found.map(|file| &file.exclude)),
277 ),
278 expected_header,
279 format: args.format,
280 offence_threshold: OffenceThreshold::new(threshold),
281 selection: RuleSelection::new(
282 Self::preferred(
283 args.rules.clone(),
284 Self::section_or_root(section.map(|s| &s.rules), found.map(|file| &file.rules)),
285 ),
286 Self::preferred(
287 args.skipped_rules.clone(),
288 Self::section_or_root(section.map(|s| &s.skip), found.map(|file| &file.skip)),
289 ),
290 ),
291 })
292 }
293
294 fn rule_listing(args: &Args) -> String {
304 let registry = RuleRegistry::from_config(&Config {
305 expected_header: vec![String::new()],
306 manifest_license: Some(String::new()),
307 ..Config::default()
308 });
309 RuleListing::new(®istry.explanations()).render(args.format)
310 }
311
312 fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
316 let path = directory.join(Self::BASELINE_NAME);
317 (writing || path.exists()).then_some(path)
318 }
319
320 fn section_or_root<'a>(
324 section: Option<&'a Vec<String>>,
325 root: Option<&'a Vec<String>>,
326 ) -> Option<&'a Vec<String>> {
327 match section {
328 Some(values) if !values.is_empty() => Some(values),
329 _ => root,
330 }
331 }
332
333 fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
334 if !from_args.is_empty() {
335 return from_args;
336 }
337 from_file.cloned().unwrap_or_default()
338 }
339
340 fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
343 manifest_path
344 .as_ref()
345 .and_then(|path| path.parent())
346 .map(Path::to_path_buf)
347 .unwrap_or_else(|| PathBuf::from("."))
348 }
349
350 pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
351
352 fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
358 let mut repaired = Vec::with_capacity(files.len());
359 let mut count = 0;
360 for file in files {
361 match TestFileRewriter::rewrite(&file) {
362 Some(contents) => {
363 let path = root.join(file.relative_path());
364 write_file(&path, &contents)
365 .with_context(|| format!("{} could not be rewritten", path.display()))?;
366 repaired.push(SourceFile::new(file.relative_path(), &contents));
367 count += 1;
368 }
369 None => repaired.push(file),
370 }
371 }
372 Ok((repaired, count))
373 }
374
375 fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
378 let path = config
379 .baseline
380 .as_ref()
381 .ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
382 let baseline = Baseline::of(&offences);
383 baseline.save(path)?;
384 println!(
385 "stern4rust wrote {} offence(s) to {}",
386 baseline.len(),
387 path.display()
388 );
389 Ok(RunOutcome::Clean)
390 }
391
392 fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
396 let Some(path) = &config.baseline else {
397 return Ok(BaselineOutcome::new(offences, 0, 0));
398 };
399 Ok(Baseline::load(path)?.apply(offences))
400 }
401
402 fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
407 let mut totals: Vec<(String, usize)> = Vec::new();
408 for counts in per_root {
409 for (pattern, count) in counts {
410 match totals.iter_mut().find(|(known, _)| *known == pattern) {
411 Some(entry) => entry.1 += count,
412 None => totals.push((pattern, count)),
413 }
414 }
415 }
416 ExclusionOutcome::new(Vec::new(), totals)
417 }
418
419 fn report(
420 config: &Config,
421 registry: &RuleRegistry,
422 totals: ScanTotals,
423 excluded: &ExclusionOutcome,
424 baselined: &BaselineOutcome,
425 rosters: &[PackageRoster],
426 offences: &[Offence],
427 ) -> String {
428 let threshold = config.offence_threshold;
429 let applied = Self::owned(®istry.names());
430 let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
431 let unconfigured: Vec<(String, String)> = registry
432 .unconfigured(config)
433 .into_iter()
434 .map(|(name, requirement)| (name.to_string(), requirement.to_string()))
435 .collect();
436 let unconfigured_names = Self::owned(®istry.unconfigured_names(config));
437 match config.format {
438 OutputFormat::Text => ReportPrinter::new(totals.files_scanned)
439 .with_threshold(threshold)
440 .with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
441 .with_package_rosters(rosters.to_vec())
442 .with_exclusions(excluded.excluded.clone())
443 .with_config_file(Self::shown(config))
444 .with_baseline(
445 Self::baseline_shown(config),
446 baselined.suppressed,
447 baselined.stale,
448 )
449 .with_fixed(totals.fixed)
450 .render(offences),
451 OutputFormat::Json => JsonPrinter::new(totals.files_scanned)
452 .with_threshold(threshold)
453 .with_rules(applied, skipped, unconfigured_names)
454 .with_package_rosters(rosters.to_vec())
455 .with_exclusions(excluded.excluded.clone())
456 .with_config_file(Self::shown(config))
457 .with_baseline(
458 Self::baseline_shown(config),
459 baselined.suppressed,
460 baselined.stale,
461 )
462 .with_fixed(totals.fixed)
463 .render(offences),
464 }
465 }
466
467 fn baseline_shown(config: &Config) -> Option<String> {
468 config
469 .baseline
470 .as_ref()
471 .map(|path| path.to_string_lossy().replace('\\', "/"))
472 }
473
474 fn shown(config: &Config) -> Option<String> {
475 config
476 .config_file
477 .as_ref()
478 .map(|path| path.to_string_lossy().replace('\\', "/"))
479 }
480
481 fn owned(names: &[&str]) -> Vec<String> {
482 names.iter().map(|name| (*name).to_string()).collect()
483 }
484}