use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::path::{Path, PathBuf};
use slopguard_ai::{
build_classifier, build_provider, cap_candidates, resolve_jev_model, run_ai_pass,
run_classifier_pass, AiCache, AiCandidate, BatchConfig, DEFAULT_MODEL,
};
use slopguard_core::cache::CacheKey;
use slopguard_core::config::Config;
use slopguard_core::finding::Finding;
use slopguard_core::rule::{ReasonMode, Rule};
use crate::scan_exec::{find_rule, ScanTargets};
use crate::AppError;
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_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
}
pub(crate) 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());
}
pub(crate) fn warn_external_ai_skipped(skipped: usize) {
if skipped > 0 {
eprintln!(
"warning: {skipped} external AI rules skipped \
(set ai.allow_external_rules = true in the user config to allow them)"
);
}
}
fn cap_ai_candidates(
candidates: Vec<AiCandidate>,
config: &Config,
) -> (Vec<AiCandidate>, Vec<Finding>) {
let max_calls = config.ai.max_calls;
let (kept, overflow) = cap_candidates(candidates, max_calls);
if !overflow.is_empty() {
eprintln!(
"warning: {} AI candidates exceed ai.max_calls ({max_calls}); \
reported without AI verification",
overflow.len()
);
}
(kept, overflow)
}
fn ai_cache(no_cache: bool, cache_dir: &Path) -> Option<AiCache> {
if no_cache {
return None;
}
match CacheKey::load_or_create_default() {
Ok(key) => Some(AiCache::new(cache_dir, key)),
Err(err) => {
eprintln!("warning: AI cache disabled ({err})");
None
}
}
}
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 (candidates, mut findings) = cap_ai_candidates(candidates, config);
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 = ai_cache(no_cache, cache_dir);
let jev_model = resolve_jev_model(&config.ai.classifier);
findings.extend(run_classifier_pass(
&*decider,
llm.as_deref(),
candidates,
&jev_model,
config.ai.classifier.threshold,
config.ai.concurrency,
BatchConfig {
batch: config.ai.classifier.batch,
max_questions: config.ai.classifier.batch_max_questions,
max_state_lines: config.ai.classifier.batch_max_state_lines,
},
cache.as_ref(),
));
Ok(findings)
}
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 (candidates, mut findings) = cap_ai_candidates(candidates, config);
let cache = ai_cache(no_cache, cache_dir);
findings.extend(run_ai_pass(
&*provider,
candidates,
config.ai.concurrency,
cache.as_ref(),
));
Ok(findings)
}