use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use tracing::debug;
use crate::config::schema::Config;
use crate::engine::ReviewIssue;
use crate::engine::postprocess::{Source, postprocess};
use crate::engine::review_store;
use crate::engine::scanner::{FileEntry, 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 {
eprintln!(
" {} skipped (unchanged since last scan)",
skipped.to_string().dimmed()
);
}
}
if files.is_empty() {
eprintln!("{}", "No files to scan.".yellow());
return Ok(0);
}
eprintln!("🔍 {} files to review…", files.len().to_string().cyan());
let total_lines: usize = files.iter().map(|f| f.lines).sum();
let root_abs = root.canonicalize().unwrap_or_else(|_| root.clone());
let mut index_skip = config.ignore.files.clone();
index_skip.extend(config.rules_config.index_skip_files.iter().cloned());
index_skip.dedup();
let index_bridge = crate::engine::index_bridge::IndexBridge::open(&root_abs);
let index_findings = crate::engine::index_scanner::scan_project_index(
&index_bridge,
&files,
config.rules_config.max_findings,
&index_skip,
);
if !index_findings.is_empty() {
eprintln!(
" {} index-based findings (unused imports, dead code)",
index_findings.len().to_string().cyan()
);
}
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 brain_ctx = if config.context_chain.use_brain {
crate::engine::review::build_scan_brain_context(
&files,
config.context_chain.impact_depth,
&index_bridge,
)
} else {
None
};
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()
};
eprintln!(" Reviewing{batch_label}…");
match crate::engine::llm::scan_files(
llm_config,
&files_content,
&effective_focus,
&config.rules,
&config.response_format,
None,
brain_ctx.as_deref(),
)
.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()
);
}
all_issues.extend(index_findings);
let all_issues = finalize_issues(config, &files, all_issues);
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");
}
{
let commit = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().to_string().into());
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().to_string().into());
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let record = review_store::ReviewRecord {
command: "scan",
project_root: &cwd,
commit_hash: commit.as_deref(),
branch: branch.as_deref(),
summary: &response.summary,
gate_status: "disabled",
files_scanned: response.files_scanned,
lines_scanned: response.lines_scanned,
should_block: response.should_block,
tokens: response.tokens_used.as_ref(),
issues: &response.issues,
};
review_store::persist_review_best_effort(&record);
}
if response.should_block && config.hook.mode == "block" {
Ok(2)
} else {
Ok(0)
}
}
fn finalize_issues(
config: &Config,
files: &[FileEntry],
issues: Vec<ReviewIssue>,
) -> Vec<ReviewIssue> {
postprocess(issues, &Source::Files(files), config)
}
#[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());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, content: &str) -> FileEntry {
FileEntry {
path: path.to_string(),
content: content.to_string(),
lines: content.lines().count(),
}
}
fn secret_file(extra: &str) -> FileEntry {
entry(
"src/app.py",
&format!("import os\npassword = \"hunter2hunter2\"{extra}\nprint(1)\n"),
)
}
fn titles(issues: &[ReviewIssue]) -> Vec<&str> {
issues.iter().map(|i| i.title.as_str()).collect()
}
fn secret_title() -> String {
let out = finalize_issues(&Config::default(), &[secret_file("")], Vec::new());
out.iter()
.find(|i| i.line == Some(2))
.unwrap_or_else(|| panic!("no finding on line 2: {:?}", titles(&out)))
.title
.clone()
}
#[test]
fn finds_hardcoded_secret_without_llm() {
let out = finalize_issues(&Config::default(), &[secret_file("")], Vec::new());
assert!(
out.iter()
.any(|i| i.file == "src/app.py" && i.line == Some(2)),
"got {:?}",
titles(&out)
);
}
#[test]
fn inline_marker_suppresses_secret() {
let title = secret_title();
let file = secret_file(&format!(" # cora-ignore: {title}"));
let out = finalize_issues(&Config::default(), &[file], Vec::new());
assert!(
!out.iter().any(|i| i.line == Some(2)),
"got {:?}",
titles(&out)
);
}
#[test]
fn ignore_rules_suppress_secret() {
let mut config = Config::default();
config.ignore.rules = vec![secret_title()];
let out = finalize_issues(&config, &[secret_file("")], Vec::new());
assert!(
!out.iter().any(|i| i.line == Some(2)),
"got {:?}",
titles(&out)
);
}
#[tokio::test]
async fn llm_failure_still_reports_deterministic_findings() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.py"), "password = \"hunter2hunter2\"\n").unwrap();
let llm = crate::engine::LLMConfig {
base_url: "http://127.0.0.1:1".to_string(),
api_key: "test".to_string(),
timeout: 2,
..Default::default()
};
let mut config = Config::default();
config.hook.mode = "block".to_string();
let opts = ScanOptions {
path: Some(dir.path().to_string_lossy().to_string()),
include: vec![],
exclude: vec![],
extensions: vec![],
incremental: false,
focus: vec![],
batch_files: 0,
continue_on_batch_error: true,
};
let code = execute_scan(&config, &llm, &opts, OutputFormat::Json)
.await
.unwrap();
assert_eq!(code, 2, "deterministic finding must survive LLM failure");
}
}