use std::collections::HashMap;
use std::env;
use std::fmt::Display;
use std::fs;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use similar::TextDiff;
use slopguard_ai::{
build_classifier, build_provider, resolve_jev_model, run_ai_pass, run_classifier_pass, AiCache,
AiCandidate, DEFAULT_MODEL,
};
use slopguard_core::baseline::{project_root, Baseline};
use slopguard_core::config::{Config, OutputFormat};
use slopguard_core::escalation::apply_escalation;
use slopguard_core::finding::{Finding, ScanResult};
use slopguard_core::fix::fix_paths;
use slopguard_core::git::{self, changed_files, GitError};
use slopguard_core::rule::{load_effective_rules, Language, ReasonMode, Rule, Severity};
use slopguard_core::scanner::{
count_severities, scan, scan_cached, scan_files, scan_files_cached, ScanError,
};
use crate::baseline_cmd::{apply_baseline, resolve_baseline};
use crate::cli::{Format, SeverityThreshold};
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,
) -> 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, 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 diff: bool,
pub base: Option<String>,
pub fix: bool,
pub dry_run: bool,
pub allow_dirty: 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 diff: bool,
pub diff_base: Option<String>,
pub offline: bool,
}
enum ScanTargets {
Walk(Vec<PathBuf>),
Files(Vec<PathBuf>),
}
impl ScanTargets {
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),
}
}
}
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), AppError> {
let CollectOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache,
cache_dir,
no_ai,
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)?;
if let Some(ref 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());
}
let (ai_rules, ast_rules): (Vec<Rule>, Vec<Rule>) =
rules.into_iter().partition(|r| r.ai_check.is_some());
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 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_ai_findings(&mut result, ai_findings);
}
}
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, config))
}
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,
diff,
base,
fix,
dry_run,
allow_dirty,
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,
offline,
});
}
let project = project_name(&paths);
let (mut result, config) = collect_findings(CollectOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache,
cache_dir,
no_ai,
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)?;
w.flush()?;
}
None => {
let stdout = io::stdout();
let mut out = stdout.lock();
write_output(&result, &format, &mut out, use_colors, &html_meta)?;
}
}
Ok(has_findings)
}
pub(crate) struct FixOpts {
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_ai: bool,
pub no_baseline: bool,
pub baseline_path: Option<PathBuf>,
pub dry_run: bool,
pub allow_dirty: bool,
pub offline: bool,
}
fn guard_clean_tree(paths: &[PathBuf]) -> Result<(), AppError> {
let probe_dir = |p: &Path| -> PathBuf {
let dir = if p.is_dir() {
p.to_path_buf()
} else {
p.parent().map(Path::to_path_buf).unwrap_or_default()
};
if dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
dir
}
};
let probe = paths
.first()
.map(|p| probe_dir(p))
.unwrap_or_else(|| PathBuf::from("."));
let dirty = match git::dirty_files(&probe) {
Ok(dirty) => dirty,
Err(GitError::NotARepository { .. }) => return Ok(()),
Err(err) => return Err(err.into()),
};
let under = under_requested_paths(dirty, paths);
if under.is_empty() {
return Ok(());
}
eprintln!(
"error: {} uncommitted change(s) under the scanned paths; commit or stash \
them, pass --allow-dirty to rewrite anyway, or preview with --dry-run",
under.len()
);
Err(AppError::Io(io::Error::other(
"refusing to --fix a dirty working tree",
)))
}
fn run_fix(opts: FixOpts) -> Result<bool, AppError> {
let FixOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_ai,
no_baseline,
baseline_path,
dry_run,
allow_dirty,
offline,
} = opts;
let mut config = resolve_config(config_path.as_deref())?;
config.rules.disable.extend(cli_disable.iter().cloned());
config.rules.enable.extend(cli_enable.iter().cloned());
config
.scan
.test_paths
.extend(cli_test_paths.iter().cloned());
let sources = resolve_config_sources(&config, offline)?;
let mut rules = load_effective_rules(&config, &sources)?;
if let Some(filter_id) = &rule_filter {
rules.retain(|r| r.id.as_str() == filter_id);
}
if no_ai {
rules.retain(|r| r.ai_check.is_none());
}
if !dry_run && !allow_dirty {
guard_clean_tree(&paths)?;
}
let resolved = resolve_baseline(no_baseline, baseline_path.clone())?;
let (baseline, baseline_root): (Option<&Baseline>, PathBuf) = match &resolved {
Some((bl, root)) => (Some(bl), root.clone()),
None => {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
(None, project_root(&cwd))
}
};
let report = fix_paths(&paths, &rules, &config, baseline, &baseline_root)?;
let stdout = io::stdout();
let mut out = stdout.lock();
if dry_run {
for file in &report.files {
let rel = file.path.display().to_string();
let diff = TextDiff::from_lines(file.original.as_str(), file.fixed.as_str());
let unified = diff
.unified_diff()
.header(&format!("a/{rel}"), &format!("b/{rel}"))
.to_string();
write!(out, "{unified}")?;
}
writeln!(
out,
"Would apply {} fix(es) across {} file(s) (dry run, nothing written)",
report.applied,
report.files_changed()
)?;
return Ok(false);
}
for file in &report.files {
fs::write(&file.path, &file.fixed)?;
}
let (mut result, _config) = collect_findings(CollectOpts {
paths,
config_path,
cli_disable,
cli_enable,
cli_test_paths,
rule_filter,
no_cache: true,
cache_dir: None,
no_ai,
diff: false,
diff_base: None,
offline,
})?;
apply_baseline(&mut result, no_baseline, baseline_path)?;
let remaining = result.findings.len();
writeln!(
out,
"Applied {} fix(es) across {} file(s); {} finding(s) remaining",
report.applied,
report.files_changed(),
remaining
)?;
Ok(remaining > 0)
}
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,
}
}
fn find_ai_rule<'a>(ai_rules: &'a [Rule], finding: &Finding) -> Option<&'a Rule> {
let lang = language_for_path(&finding.file);
ai_rules
.iter()
.find(|r| r.id == finding.rule_id && Some(&r.language) == lang.as_ref())
.or_else(|| ai_rules.iter().find(|r| r.id == finding.rule_id))
}
fn build_candidates(
findings: Vec<Finding>,
ai_rules: &[Rule],
config: &Config,
) -> Vec<AiCandidate> {
let mut contents: HashMap<PathBuf, Option<String>> = HashMap::new();
let mut candidates = Vec::new();
for finding in findings {
let Some(rule) = find_ai_rule(ai_rules, &finding) else {
continue;
};
let Some(ai_check) = rule.ai_check.as_ref() else {
continue;
};
let content = contents
.entry(finding.file.clone())
.or_insert_with(|| fs::read_to_string(&finding.file).ok());
let Some(file_content) = content.clone() else {
continue;
};
let model = ai_check
.model
.clone()
.or_else(|| config.ai.model.clone())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let rule_context = rule.note.clone().unwrap_or_else(|| rule.message.clone());
candidates.push(AiCandidate {
finding,
file_content,
prompt_template: ai_check.prompt.clone(),
model,
rule_context,
reason_mode: ai_check.reason,
threshold: ai_check.threshold,
if_true: ai_check.if_true.clone(),
if_false: ai_check.if_false.clone(),
});
}
candidates
}
fn merge_ai_findings(result: &mut ScanResult, ai_findings: Vec<Finding>) {
result.findings.extend(ai_findings);
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;
}
fn run_ai_phase(
targets: &ScanTargets,
ai_rules: &[Rule],
config: &Config,
no_cache: bool,
cache_dir: &Path,
) -> Result<Vec<Finding>, AppError> {
if config.ai.classifier.enabled {
return run_classifier_phase(targets, ai_rules, config, no_cache, cache_dir);
}
run_llm_phase(targets, ai_rules, config, no_cache, cache_dir)
}
fn collect_ai_candidates(
targets: &ScanTargets,
ai_rules: &[Rule],
config: &Config,
cache_dir: &Path,
) -> Result<Vec<AiCandidate>, AppError> {
let candidate_result = targets.run(ai_rules, config, true, cache_dir)?;
Ok(build_candidates(
candidate_result.findings,
ai_rules,
config,
))
}
fn warn_ai_skipped(ai_rules: &[Rule], err: &dyn Display) {
eprintln!("warning: {} AI rules skipped ({err})", ai_rules.len());
}
fn run_classifier_phase(
targets: &ScanTargets,
ai_rules: &[Rule],
config: &Config,
no_cache: bool,
cache_dir: &Path,
) -> Result<Vec<Finding>, AppError> {
let decider = match build_classifier(&config.ai.classifier) {
Ok(decider) => decider,
Err(err) => {
warn_ai_skipped(ai_rules, &err);
return Ok(Vec::new());
}
};
let candidates = collect_ai_candidates(targets, ai_rules, config, cache_dir)?;
if candidates.is_empty() {
return Ok(Vec::new());
}
let needs_llm = candidates
.iter()
.any(|c| c.reason_mode == ReasonMode::Generated);
let llm = needs_llm.then(|| build_provider(&config.ai).ok()).flatten();
let cache = (!no_cache).then(|| AiCache::new(cache_dir));
let jev_model = resolve_jev_model(&config.ai.classifier);
Ok(run_classifier_pass(
&*decider,
llm.as_deref(),
candidates,
&jev_model,
config.ai.classifier.threshold,
config.ai.concurrency,
cache.as_ref(),
))
}
fn run_llm_phase(
targets: &ScanTargets,
ai_rules: &[Rule],
config: &Config,
no_cache: bool,
cache_dir: &Path,
) -> Result<Vec<Finding>, AppError> {
let provider = match build_provider(&config.ai) {
Ok(provider) => provider,
Err(err) => {
warn_ai_skipped(ai_rules, &err);
return Ok(Vec::new());
}
};
let candidates = collect_ai_candidates(targets, ai_rules, config, cache_dir)?;
if candidates.is_empty() {
return Ok(Vec::new());
}
let cache = (!no_cache).then(|| AiCache::new(cache_dir));
Ok(run_ai_pass(
&*provider,
candidates,
config.ai.concurrency,
cache.as_ref(),
))
}