use crate::adoption::baseline::Baseline;
use crate::adoption::baseline_outcome::BaselineOutcome;
use crate::adoption::exclusion_outcome::ExclusionOutcome;
use crate::adoption::exclusion_set::ExclusionSet;
use crate::reporting::json_printer::JsonPrinter;
use crate::reporting::offence::Offence;
use crate::reporting::offence_threshold::OffenceThreshold;
use crate::reporting::output_format::OutputFormat;
use crate::reporting::report_printer::ReportPrinter;
use crate::reporting::run_outcome::RunOutcome;
use crate::rule_registry::RuleRegistry;
use crate::rules::source::header_rule::HeaderRule;
use crate::settings::args::Args;
use crate::settings::config::Config;
use crate::settings::config_file::ConfigFile;
use crate::settings::header_source::HeaderSource;
use crate::settings::manifest_resolver::ManifestResolver;
use crate::settings::rule_selection::RuleSelection;
use crate::source_file::SourceFile;
use crate::source_reader::SourceReader;
use crate::source_walker::SourceWalker;
use crate::test_file_rewriter::TestFileRewriter;
use anyhow::Context;
use anyhow::Result;
use std::collections::HashSet;
use std::fs::write as write_file;
use std::path::Path;
use std::path::PathBuf;
pub struct Runner;
impl Runner {
pub fn run(args: Args) -> Result<RunOutcome> {
let config = Self::config_from(args)?;
Self::validate_selection(&config)?;
let config = Config {
manifest_license: ManifestResolver::license(&config),
workspace_dependencies: ManifestResolver::workspace_dependencies(&config),
..config
};
let registry = RuleRegistry::from_config(&config);
if registry.is_empty() {
return Err(anyhow::anyhow!(
"no rules are configured, so nothing would be checked -- pass --header-file to \
enable the header rule"
));
}
let roots = ManifestResolver::package_roots(&config)?;
let exclusions = ExclusionSet::new(&config.excludes)?;
let mut offences = Vec::new();
let mut files_scanned = 0usize;
let mut excluded = Vec::new();
let mut fixed = 0usize;
for root in &roots {
let outcome = exclusions.apply(SourceWalker::walk(root), root);
let mut files: Vec<SourceFile> = Vec::new();
for path in outcome.kept {
files_scanned += 1;
match SourceReader::read(root, &path) {
Ok(file) => files.push(file),
Err(offence) => offences.push(*offence),
}
}
if config.fix {
let (rewritten, count) = Self::repair(root, files)?;
files = rewritten;
fixed += count;
}
for file in &files {
offences.extend(registry.check(file));
}
offences.extend(registry.check_workspace(&files));
excluded.push(outcome.excluded);
}
let mut seen = HashSet::new();
offences.retain(|offence| seen.insert(offence.clone()));
offences.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
if config.write_baseline {
return Self::record(&config, offences);
}
let baselined = Self::baselined(&config, offences)?;
let offences = baselined.kept;
Self::report(
&config,
®istry,
files_scanned,
&Self::merged(excluded),
&BaselineOutcome::new(Vec::new(), baselined.suppressed, baselined.stale),
fixed,
&offences,
);
Ok(RunOutcome::of(offences.len()))
}
fn validate_selection(config: &Config) -> Result<()> {
let known = RuleRegistry::known_names();
let unknown = config.selection.unknown_in(&known);
if !unknown.is_empty() {
return Err(anyhow::anyhow!(
"unknown rule name(s): {} -- the rules are: {}",
unknown.join(", "),
known.join(", ")
));
}
if config.selection.selects_explicitly(HeaderRule::NAME)
&& config.expected_header.is_empty()
{
return Err(anyhow::anyhow!(
"--rule {} needs --header-file, otherwise the run would apply no rules at all",
HeaderRule::NAME
));
}
Ok(())
}
fn config_from(args: Args) -> Result<Config> {
let directory = Self::manifest_directory(&args.manifest_path);
let file = ConfigFile::load(&directory)?;
let found = file.as_ref();
let header_file = args
.header_file
.or_else(|| found.and_then(|file| file.header_file_from(&directory)));
let expected_header = match &header_file {
Some(path) => HeaderSource::read(path)?,
None => Vec::new(),
};
let threshold = args
.offence_threshold
.or_else(|| found.and_then(|file| file.offence_threshold))
.unwrap_or(OffenceThreshold::DEFAULT);
Ok(Config {
manifest_license: None,
workspace_dependencies: None,
baseline: args
.baseline
.or_else(|| found.and_then(|file| file.baseline_from(&directory)))
.or_else(|| Self::discovered_baseline(&directory, args.write_baseline)),
write_baseline: args.write_baseline,
fix: args.fix,
config_file: found.map(|_| directory.join(ConfigFile::NAME)),
manifest_path: args.manifest_path,
max_files_per_directory: found.and_then(|file| file.max_files_per_directory),
max_subfolders_per_directory: found.and_then(|file| file.max_subfolders_per_directory),
packages: args.packages,
excludes: Self::preferred(args.excludes, found.map(|file| &file.exclude)),
expected_header,
format: args.format,
offence_threshold: OffenceThreshold::new(threshold),
selection: RuleSelection::new(
Self::preferred(args.rules, found.map(|file| &file.rules)),
Self::preferred(args.skipped_rules, found.map(|file| &file.skip)),
),
})
}
fn discovered_baseline(directory: &Path, writing: bool) -> Option<PathBuf> {
let path = directory.join(Self::BASELINE_NAME);
(writing || path.exists()).then_some(path)
}
fn preferred(from_args: Vec<String>, from_file: Option<&Vec<String>>) -> Vec<String> {
if !from_args.is_empty() {
return from_args;
}
from_file.cloned().unwrap_or_default()
}
fn manifest_directory(manifest_path: &Option<PathBuf>) -> PathBuf {
manifest_path
.as_ref()
.and_then(|path| path.parent())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
pub const BASELINE_NAME: &'static str = "stern4rust-baseline.json";
fn repair(root: &Path, files: Vec<SourceFile>) -> Result<(Vec<SourceFile>, usize)> {
let mut repaired = Vec::with_capacity(files.len());
let mut count = 0;
for file in files {
match TestFileRewriter::rewrite(&file) {
Some(contents) => {
let path = root.join(file.relative_path());
write_file(&path, &contents)
.with_context(|| format!("{} could not be rewritten", path.display()))?;
repaired.push(SourceFile::new(file.relative_path(), &contents));
count += 1;
}
None => repaired.push(file),
}
}
Ok((repaired, count))
}
fn record(config: &Config, offences: Vec<Offence>) -> Result<RunOutcome> {
let path = config
.baseline
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--write-baseline needs a path to write to"))?;
let baseline = Baseline::of(&offences);
baseline.save(path)?;
println!(
"stern4rust wrote {} offence(s) to {}",
baseline.len(),
path.display()
);
Ok(RunOutcome::Clean)
}
fn baselined(config: &Config, offences: Vec<Offence>) -> Result<BaselineOutcome> {
let Some(path) = &config.baseline else {
return Ok(BaselineOutcome::new(offences, 0, 0));
};
Ok(Baseline::load(path)?.apply(offences))
}
fn merged(per_root: Vec<Vec<(String, usize)>>) -> ExclusionOutcome {
let mut totals: Vec<(String, usize)> = Vec::new();
for counts in per_root {
for (pattern, count) in counts {
match totals.iter_mut().find(|(known, _)| *known == pattern) {
Some(entry) => entry.1 += count,
None => totals.push((pattern, count)),
}
}
}
ExclusionOutcome::new(Vec::new(), totals)
}
fn report(
config: &Config,
registry: &RuleRegistry,
files_scanned: usize,
excluded: &ExclusionOutcome,
baselined: &BaselineOutcome,
fixed: usize,
offences: &[Offence],
) {
let threshold = config.offence_threshold;
let applied = Self::owned(®istry.names());
let skipped = Self::owned(&RuleRegistry::skipped_names(&config.selection));
let unconfigured = Self::owned(®istry.unconfigured_names(config));
match config.format {
OutputFormat::Text => ReportPrinter::new(files_scanned)
.with_threshold(threshold)
.with_rules(applied.clone(), skipped.clone(), unconfigured.clone())
.with_exclusions(excluded.excluded.clone())
.with_config_file(Self::shown(config))
.with_baseline(
Self::baseline_shown(config),
baselined.suppressed,
baselined.stale,
)
.with_fixed(fixed)
.print(offences),
OutputFormat::Json => JsonPrinter::new(files_scanned)
.with_threshold(threshold)
.with_rules(applied, skipped, unconfigured)
.with_exclusions(excluded.excluded.clone())
.with_config_file(Self::shown(config))
.with_baseline(
Self::baseline_shown(config),
baselined.suppressed,
baselined.stale,
)
.with_fixed(fixed)
.print(offences),
}
}
fn baseline_shown(config: &Config) -> Option<String> {
config
.baseline
.as_ref()
.map(|path| path.to_string_lossy().replace('\\', "/"))
}
fn shown(config: &Config) -> Option<String> {
config
.config_file
.as_ref()
.map(|path| path.to_string_lossy().replace('\\', "/"))
}
fn owned(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
}