use std::env;
use std::fs;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use slopguard_core::config::{Config, OutputFormat};
use slopguard_core::escalation::apply_escalation;
use slopguard_core::finding::{Finding, ScanResult};
use slopguard_core::git::changed_files;
use slopguard_core::rule::{load_effective_rules, Language, Rule, Severity};
use slopguard_core::scanner::{
count_severities, scan, scan_cached, scan_files, scan_files_cached, scan_files_unused_disables,
scan_unused_disables, ScanError,
};
use slopguard_core::source::RuleOrigin;
use crate::ai_phase::{run_ai_phase, warn_external_ai_skipped};
use crate::baseline_cmd::apply_baseline;
use crate::cli::{Format, SeverityThreshold};
use crate::fix_exec::{run_fix, FixOpts};
use crate::output::html::{project_name, HtmlMeta};
use crate::output::{html, json, sarif, text};
use crate::stats_cmd::compute_report;
use crate::{resolve_cache_dir, resolve_config, resolve_config_sources, suggest_similar, AppError};
fn has_findings_above_threshold(result: &ScanResult, threshold: &SeverityThreshold) -> bool {
result.findings.iter().any(|f| match threshold {
SeverityThreshold::Warning => true,
SeverityThreshold::Error => f.severity == Severity::Error,
})
}
fn write_output(
result: &ScanResult,
format: &Format,
w: &mut impl Write,
use_colors: bool,
html_meta: &HtmlMeta,
rules: &[Rule],
) -> io::Result<()> {
match format {
Format::Text => text::format_text(result, w, use_colors),
Format::Json => json::format_json(result, w),
Format::Sarif => sarif::format_sarif(result, rules, w),
Format::Html => html::format_html(result, &compute_report(result), html_meta, w),
}
}
pub(crate) struct ScanOpts {
pub paths: Vec<PathBuf>,
pub format: Option<Format>,
pub output: Option<PathBuf>,
pub severity_threshold: SeverityThreshold,
pub config_path: Option<PathBuf>,
pub no_colors: bool,
pub cli_disable: Vec<String>,
pub cli_enable: Vec<String>,
pub cli_test_paths: Vec<String>,
pub rule_filter: Option<String>,
pub no_cache: bool,
pub cache_dir: Option<PathBuf>,
pub no_ai: bool,
pub no_baseline: bool,
pub baseline_path: Option<PathBuf>,
pub no_escalation: bool,
pub report_unused_disable: bool,
pub diff: bool,
pub base: Option<String>,
pub fix: bool,
pub dry_run: bool,
pub allow_dirty: bool,
pub unsafe_fixes: bool,
pub offline: bool,
}
pub struct CollectOpts {
pub paths: Vec<PathBuf>,
pub config_path: Option<PathBuf>,
pub cli_disable: Vec<String>,
pub cli_enable: Vec<String>,
pub cli_test_paths: Vec<String>,
pub rule_filter: Option<String>,
pub no_cache: bool,
pub cache_dir: Option<PathBuf>,
pub no_ai: bool,
pub report_unused: bool,
pub diff: bool,
pub diff_base: Option<String>,
pub offline: bool,
}
pub(crate) enum ScanTargets {
Walk(Vec<PathBuf>),
Files(Vec<PathBuf>),
}
impl ScanTargets {
pub(crate) fn run(
&self,
rules: &[Rule],
config: &Config,
no_cache: bool,
cache_dir: &Path,
) -> Result<ScanResult, ScanError> {
match (self, no_cache) {
(ScanTargets::Walk(p), true) => scan(p, rules, config),
(ScanTargets::Walk(p), false) => scan_cached(p, rules, config, cache_dir),
(ScanTargets::Files(f), true) => scan_files(f, rules, config),
(ScanTargets::Files(f), false) => scan_files_cached(f, rules, config, cache_dir),
}
}
}
pub(crate) fn under_requested_paths(files: Vec<PathBuf>, paths: &[PathBuf]) -> Vec<PathBuf> {
let roots: Vec<PathBuf> = paths
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect();
files
.into_iter()
.filter(|file| {
let resolved = file.canonicalize().unwrap_or_else(|_| file.clone());
roots.iter().any(|root| resolved.starts_with(root))
})
.collect()
}
pub fn collect_findings(opts: CollectOpts) -> Result<(ScanResult, Config, Vec<Rule>), AppError> {
let CollectOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache,
cache_dir,
no_ai,
report_unused,
diff,
diff_base,
offline,
} = opts;
let mut config = resolve_config(config_path.as_deref())?;
config.rules.disable.extend(cli_disable);
config.rules.enable.extend(cli_enable);
config.scan.test_paths.extend(cli_test_paths);
let sources = resolve_config_sources(&config, offline)?;
let mut rules = load_effective_rules(&config, &sources)?;
retain_requested_rules(&mut rules, rule_filter.as_deref(), no_ai)?;
let targets = if diff {
let cwd = env::current_dir().map_err(AppError::Io)?;
let changed = under_requested_paths(changed_files(&cwd, diff_base.as_deref())?, &paths);
ScanTargets::Files(changed)
} else {
ScanTargets::Walk(paths)
};
let result = scan_with_rules(
targets,
rules.clone(),
&config,
no_cache,
cache_dir,
report_unused,
diff_base,
)?;
Ok((result, config, rules))
}
pub(crate) fn retain_requested_rules(
rules: &mut Vec<Rule>,
rule_filter: Option<&str>,
no_ai: bool,
) -> Result<(), AppError> {
if let Some(filter_id) = rule_filter {
let found = rules.iter().any(|r| r.id.as_str() == filter_id);
if !found {
let suggestion = suggest_similar(filter_id, rules);
if let Some(suggested) = suggestion {
eprintln!("error: unknown rule '{filter_id}'. Did you mean '{suggested}'?");
} else {
eprintln!("error: unknown rule '{filter_id}'");
}
return Err(AppError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("unknown rule '{filter_id}'"),
)));
}
rules.retain(|r| r.id.as_str() == filter_id);
}
if no_ai {
rules.retain(|r| r.ai_check.is_none());
}
Ok(())
}
pub(crate) fn scan_with_rules(
targets: ScanTargets,
rules: Vec<Rule>,
config: &Config,
no_cache: bool,
cache_dir: Option<PathBuf>,
report_unused: bool,
diff_base: Option<String>,
) -> Result<ScanResult, AppError> {
let (mut ai_rules, ast_rules): (Vec<Rule>, Vec<Rule>) =
rules.into_iter().partition(|r| r.ai_check.is_some());
if !config.ai.allow_external_rules {
let before = ai_rules.len();
ai_rules.retain(|r| r.origin == RuleOrigin::Builtin);
warn_external_ai_skipped(before - ai_rules.len());
}
let resolved_cache_dir = resolve_cache_dir(cache_dir, config);
let mut result = targets.run(&ast_rules, config, no_cache, &resolved_cache_dir)?;
if !ai_rules.is_empty() {
let ai_findings = run_ai_phase(&targets, &ai_rules, config, no_cache, &resolved_cache_dir)?;
if !ai_findings.is_empty() {
merge_findings(&mut result, ai_findings);
}
}
if report_unused {
let mut all_rules = ast_rules;
all_rules.extend(ai_rules);
let unused = match &targets {
ScanTargets::Walk(p) => scan_unused_disables(p, &all_rules, config)?,
ScanTargets::Files(f) => scan_files_unused_disables(f, &all_rules, config)?,
};
if !unused.is_empty() {
merge_findings(&mut result, unused);
}
}
if let ScanTargets::Files(ref files) = targets {
result.stats.diff_base = Some(diff_base.unwrap_or_else(|| "HEAD".to_string()));
result.stats.files_changed = Some(files.len());
}
Ok(result)
}
pub(crate) fn run_scan(opts: ScanOpts) -> Result<bool, AppError> {
let ScanOpts {
paths,
format,
output,
severity_threshold,
config_path,
no_colors,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache,
cache_dir,
no_ai,
no_baseline,
baseline_path,
no_escalation,
report_unused_disable,
diff,
base,
fix,
dry_run,
allow_dirty,
unsafe_fixes,
offline,
} = opts;
let use_colors = !no_colors && env::var_os("NO_COLOR").is_none();
if fix {
return run_fix(FixOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_ai,
no_baseline,
baseline_path,
dry_run,
allow_dirty,
unsafe_fixes,
offline,
});
}
let project = project_name(&paths);
let (mut result, config, rules) = collect_findings(CollectOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache,
cache_dir,
no_ai,
report_unused: report_unused_disable,
diff,
diff_base: base,
offline,
})?;
let baseline_active = apply_baseline(&mut result, no_baseline, baseline_path)?;
if !no_escalation {
apply_escalation(&mut result, &config.escalation);
}
let format = format.unwrap_or(match config.output.format {
OutputFormat::Text => Format::Text,
OutputFormat::Json => Format::Json,
OutputFormat::Sarif => Format::Sarif,
OutputFormat::Html => Format::Html,
});
let has_findings = has_findings_above_threshold(&result, &severity_threshold);
let html_meta = HtmlMeta {
project_name: project,
generated_at_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
baseline_active,
};
match output {
Some(path) => {
let file = fs::File::create(&path)?;
let mut w = BufWriter::new(file);
write_output(&result, &format, &mut w, false, &html_meta, &rules)?;
w.flush()?;
}
None => {
let stdout = io::stdout();
let mut out = stdout.lock();
write_output(&result, &format, &mut out, use_colors, &html_meta, &rules)?;
}
}
Ok(has_findings)
}
pub(crate) fn language_for_path(path: &Path) -> Option<Language> {
match path.extension().and_then(|e| e.to_str()) {
Some("rs") => Some(Language::Rust),
Some("ts" | "tsx") => Some(Language::TypeScript),
_ => None,
}
}
pub(crate) fn find_rule<'a>(rules: &'a [Rule], finding: &Finding) -> Option<&'a Rule> {
let lang = language_for_path(&finding.file);
rules
.iter()
.find(|r| r.id == finding.rule_id && Some(&r.language) == lang.as_ref())
.or_else(|| rules.iter().find(|r| r.id == finding.rule_id))
}
fn merge_findings(result: &mut ScanResult, extra: Vec<Finding>) {
result.findings.extend(extra);
result
.findings
.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
result.findings.dedup_by(|a, b| {
a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
});
let (errors, warnings) = count_severities(&result.findings);
result.stats.errors = errors;
result.stats.warnings = warnings;
result.stats.total = errors + warnings;
}