use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use tracing::debug;
use crate::config::schema::Config;
use crate::engine::scanner::{batch_files, format_batch_for_prompt, walk_project};
use crate::engine::types::TokenUsage;
use crate::formatters::{OutputFormat, formatter_for};
const DEFAULT_MAX_FILES_PER_BATCH: usize = 20;
const DEFAULT_BATCH_TOKEN_BUDGET: usize = 60_000;
pub struct ScanOptions {
pub path: Option<String>,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub extensions: Vec<String>,
pub incremental: bool,
pub focus: Vec<String>,
pub batch_files: usize,
pub continue_on_batch_error: bool,
}
#[allow(clippy::too_many_lines)]
pub async fn execute_scan(
config: &Config,
llm_config: &crate::engine::LLMConfig,
opts: &ScanOptions,
format: OutputFormat,
) -> Result<i32> {
let root = match &opts.path {
Some(p) => Path::new(p).to_path_buf(),
None => std::env::current_dir()?,
};
if !root.is_dir() {
anyhow::bail!("scan path '{}' is not a directory", root.display());
}
let include = opts.include.clone();
let mut exclude = config.ignore.files.clone();
exclude.extend(opts.exclude.clone());
let effective_focus = if opts.focus.is_empty() {
config.focus.clone()
} else {
opts.focus.clone()
};
debug!(root = %root.display(), "starting scan");
let mut files = walk_project(&root, &include, &exclude, &opts.extensions)?;
if opts.incremental {
let cache = ScanCache::load()?;
let before_count = files.len();
let root_abs = root.canonicalize().unwrap_or_else(|_| root.clone());
files.retain(|f| {
let abs_path = root_abs.join(&f.path);
let Some(hash) = file_content_hash(&abs_path) else {
return true; };
match cache.get(&root_abs, &f.path) {
Some(cached_hash) if cached_hash == hash => {
debug!(file = %f.path, "skipping unchanged file (incremental)");
false
}
_ => true,
}
});
let skipped = before_count - files.len();
if skipped > 0 {
println!(
" {} skipped (unchanged since last scan)",
skipped.to_string().dimmed()
);
}
}
if files.is_empty() {
println!("{}", "No files to scan.".yellow());
return Ok(0);
}
println!("🔍 {} files to review…", files.len().to_string().cyan());
let total_lines: usize = files.iter().map(|f| f.lines).sum();
let max_files_per_batch = if opts.batch_files > 0 {
opts.batch_files
} else {
DEFAULT_MAX_FILES_PER_BATCH
};
let batches = batch_files(&files, DEFAULT_BATCH_TOKEN_BUDGET, max_files_per_batch);
debug!(
batches = batches.len(),
max_files = max_files_per_batch,
"batched files"
);
let mut all_issues = Vec::new();
let mut total_tokens: Option<TokenUsage> = None;
let mut skipped_batches: Vec<(usize, Vec<String>, String)> = Vec::new();
for (batch_idx, batch) in batches.iter().enumerate() {
let files_content = format_batch_for_prompt(batch);
let batch_label = if batches.len() > 1 {
format!(" (batch {}/{})", batch_idx + 1, batches.len())
} else {
String::new()
};
println!(" Reviewing{batch_label}…");
match crate::engine::llm::scan_files(
llm_config,
&files_content,
&effective_focus,
&config.rules,
&config.response_format,
None,
)
.await
{
Ok((issues, _summary, tokens)) => {
all_issues.extend(issues);
total_tokens = match (total_tokens, tokens) {
(Some(mut acc), Some(t)) => {
acc.input_tokens += t.input_tokens;
acc.output_tokens += t.output_tokens;
acc.estimated_cost_usd += t.estimated_cost_usd;
Some(acc)
}
(None, Some(t)) => Some(t),
(acc, None) => acc,
};
}
Err(err) => {
let file_list: Vec<String> =
batch.iter().map(|f| f.path.clone()).collect::<Vec<_>>();
let err_string = err.to_string();
tracing::warn!(
batch = batch_idx + 1,
total_batches = batches.len(),
files = ?file_list,
error = %err_string,
"batch scan failed"
);
if !opts.continue_on_batch_error {
eprintln!(
" {} batch {}/{}: {}",
"failed".red().bold(),
batch_idx + 1,
batches.len(),
err_string
);
return Err(err.into());
}
eprintln!(
" {} batch {}/{} — skipping ({} files): {}",
"warn".yellow().bold(),
batch_idx + 1,
batches.len(),
file_list.len(),
err_string
);
skipped_batches.push((batch_idx + 1, file_list, err_string));
}
}
}
if !skipped_batches.is_empty() {
eprintln!(
" {} {} of {} batches skipped due to parse failures.",
skipped_batches.len().to_string().yellow(),
skipped_batches.len(),
batches.len()
);
}
let issue_count = all_issues.len();
let min_severity = config.hook.min_severity_level();
let should_block = all_issues.iter().any(|i| i.severity <= min_severity);
let response = crate::engine::ScanResponse {
issues: all_issues,
files_scanned: files.len(),
lines_scanned: total_lines,
summary: format!(
"Scanned {} files ({} lines), found {} issues.",
files.len(),
total_lines,
issue_count
),
tokens_used: total_tokens,
should_block,
};
let formatter = formatter_for(format);
let output = formatter.format_scan(&response)?;
println!("{output}");
if opts.incremental {
let root_abs = root.canonicalize().unwrap_or_else(|_| root.clone());
let mut cache = ScanCache::load().unwrap_or_default();
for f in &files {
let abs_path = root_abs.join(&f.path);
let Some(hash) = file_content_hash(&abs_path) else {
continue; };
cache.set(&root_abs, &f.path, &hash);
}
cache.save()?;
debug!(cached = files.len(), "saved scan cache");
}
if response.should_block && config.hook.mode == "block" {
Ok(2)
} else {
Ok(0)
}
}
#[allow(clippy::format_collect)]
fn file_content_hash(path: &std::path::Path) -> Option<String> {
use sha2::Digest;
let bytes = std::fs::read(path).ok()?;
let hash = sha2::Sha256::digest(&bytes);
Some(hash.iter().take(8).map(|b| format!("{b:02x}")).collect())
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct ScanCache {
projects: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
}
impl ScanCache {
fn cache_path() -> anyhow::Result<std::path::PathBuf> {
let home = dirs::home_dir().context("cannot determine home directory")?;
Ok(home.join(".cora").join("scan-cache.json"))
}
fn load() -> Result<Self> {
let path = Self::cache_path()?;
if !path.is_file() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content).context("failed to parse scan cache")
}
fn save(&self) -> Result<()> {
let path = Self::cache_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
fn get(&self, root: &std::path::Path, file: &str) -> Option<String> {
let root_key = root.to_string_lossy().to_string();
self.projects.get(&root_key)?.get(file).cloned()
}
fn set(&mut self, root: &std::path::Path, file: &str, hash: &str) {
let root_key = root.to_string_lossy().to_string();
self.projects
.entry(root_key)
.or_default()
.insert(file.to_string(), hash.to_string());
}
}