use tracing::debug;
use crate::engine::Severity;
use crate::engine::diff_parser::{DiffLineType, FileChunk};
use crate::engine::rules::types::RuleFinding;
use crate::index::graph;
pub fn should_skip_file(file_path: &str, skip_patterns: &[String]) -> bool {
if skip_patterns.is_empty() {
return false;
}
let basename = std::path::Path::new(file_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
for pattern in skip_patterns {
if file_path == pattern {
return true;
}
if basename == *pattern {
return true;
}
if !pattern.contains('*') {
continue;
}
if let Some(rest) = pattern.strip_prefix("**/") {
if let Some(ext) = rest.strip_prefix("*.") {
if basename.ends_with(ext) {
return true;
}
}
}
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; if basename.ends_with(suffix) {
return true;
}
}
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 2]; if basename.starts_with(prefix) {
return true;
}
}
if let Some(dir) = pattern
.strip_prefix("**/")
.and_then(|s| s.strip_suffix("/**"))
{
let components: Vec<&str> = file_path.split('/').collect();
if components.contains(&dir) {
return true;
}
}
if let Some(dir) = pattern.strip_suffix("/**") {
if file_path.starts_with(&format!("{dir}/")) || file_path == dir {
return true;
}
}
if let Some(rest) = pattern.strip_prefix("**/") {
if rest.contains('*') {
continue;
}
if basename == rest || file_path.ends_with(&format!("/{rest}")) {
return true;
}
}
}
false
}
pub fn scan_unused_imports(
chunks: &[FileChunk],
project_root: &std::path::Path,
max_findings: usize,
skip_patterns: &[String],
) -> Vec<RuleFinding> {
let conn = match crate::index::open_global_index() {
Ok(c) => c,
Err(_) => {
debug!("no global index available — skipping unused import scan");
return Vec::new();
}
};
let project_id = match crate::index::ensure_project(&conn, project_root) {
Ok(id) => id,
Err(_) => {
debug!("failed to get project_id — skipping unused import scan");
return Vec::new();
}
};
let mut findings = Vec::new();
let mut seen_files = std::collections::HashSet::new();
for chunk in chunks {
let file = chunk
.new_path
.as_deref()
.or(chunk.old_path.as_deref())
.unwrap_or("unknown");
if chunk.new_path.is_none() {
continue;
}
if should_skip_file(file, skip_patterns) {
continue;
}
let has_additions = chunk
.chunks
.iter()
.any(|h| h.lines.iter().any(|l| l.line_type == DiffLineType::Add));
if !has_additions {
continue;
}
if seen_files.insert(file.to_string()) {
match graph::find_unused_imports(&conn, file, project_id) {
Ok(unused) => {
for u in &unused {
findings.push(RuleFinding {
rule_id: "index-unused-import".to_string(),
file: u.file.clone(),
line: u.line,
severity: Severity::Minor,
title: format!("[index-unused-import] Unused import: {}", u.target),
body: format!(
"Import `{}` is never used in this file. \
Consider removing it to keep imports clean.",
u.target
),
});
}
}
Err(e) => {
debug!("unused import scan failed for {}: {}", file, e);
}
}
}
if findings.len() >= max_findings {
break;
}
}
findings.truncate(max_findings);
debug!(count = findings.len(), "unused import scan complete");
findings
}
pub fn scan_dead_code_in_review(
chunks: &[FileChunk],
project_root: &std::path::Path,
max_findings: usize,
skip_patterns: &[String],
) -> Vec<RuleFinding> {
let conn = match crate::index::open_global_index() {
Ok(c) => c,
Err(_) => {
debug!("no global index available — skipping dead code scan");
return Vec::new();
}
};
let project_id = match crate::index::ensure_project(&conn, project_root) {
Ok(id) => id,
Err(_) => {
debug!("failed to get project_id — skipping dead code scan");
return Vec::new();
}
};
let mut findings = Vec::new();
let mut seen_files = std::collections::HashSet::new();
for chunk in chunks {
let file = chunk
.new_path
.as_deref()
.or(chunk.old_path.as_deref())
.unwrap_or("unknown");
if chunk.new_path.is_none() {
continue;
}
if should_skip_file(file, skip_patterns) {
continue;
}
if seen_files.insert(file.to_string()) {
match graph::find_dead_code_in_file(&conn, file, project_id, false) {
Ok(dead) => {
for d in &dead {
findings.push(RuleFinding {
rule_id: "index-dead-code".to_string(),
file: d.file.clone(),
line: d.line,
severity: Severity::Info,
title: format!("[index-dead-code] Potentially dead code: {}", d.name),
body: format!(
"Function `{}` ({}) has no callers in the \
project. Verify it's not called via reflection, \
trait dispatch, or external entry points.",
d.name, d.kind
),
});
}
}
Err(e) => {
debug!("dead code scan failed for {}: {}", file, e);
}
}
}
if findings.len() >= max_findings {
break;
}
}
findings.truncate(max_findings);
debug!(count = findings.len(), "dead code scan complete");
findings
}
pub fn scan_breaking_changes(
chunks: &[FileChunk],
project_root: &std::path::Path,
max_findings: usize,
skip_patterns: &[String],
) -> Vec<RuleFinding> {
let conn = match crate::index::open_global_index() {
Ok(c) => c,
Err(_) => {
debug!("no global index available — skipping breaking change scan");
return Vec::new();
}
};
let project_id = match crate::index::ensure_project(&conn, project_root) {
Ok(id) => id,
Err(_) => {
debug!("failed to get project_id — skipping breaking change scan");
return Vec::new();
}
};
let mut findings = Vec::new();
let removal_patterns: &[&str] = &[
r"(?m)^(?:pub\s+)?(?:fn|struct|enum|trait|mod|type|const|static)\s+(\w+)",
r"(?m)^export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)",
r"(?m)^(?:func|type|var|const)\s+(\w+)",
r"(?m)^(?:async\s+)?(?:def|class)\s+(\w+)",
];
let compiled: Vec<std::sync::Arc<regex::Regex>> = removal_patterns
.iter()
.filter_map(|p| regex::Regex::new(p).ok())
.map(std::sync::Arc::new)
.collect();
for chunk in chunks {
let file = chunk
.new_path
.as_deref()
.or(chunk.old_path.as_deref())
.unwrap_or("unknown");
if should_skip_file(file, skip_patterns) {
continue;
}
for hunk in &chunk.chunks {
for line in &hunk.lines {
if line.line_type != DiffLineType::Remove {
continue;
}
for re in &compiled {
if let Some(caps) = re.captures(&line.content) {
let symbol_name = &caps[1];
if symbol_name.len() < 3 {
continue;
}
let line_no = line.old_line_no.unwrap_or(0);
match graph::find_callers(&conn, project_id, symbol_name, 10) {
Ok(callers) if !callers.is_empty() => {
let caller_list = callers
.iter()
.take(3)
.map(|c| format!("{} ({}:{})", c.caller, c.file, c.line))
.collect::<Vec<_>>()
.join(", ");
findings.push(RuleFinding {
rule_id: "index-breaking-change".to_string(),
file: file.to_string(),
line: line_no,
severity: Severity::Major,
title: format!(
"[index-breaking-change] Removing `{}` \
breaks {} caller(s)",
symbol_name,
callers.len()
),
body: format!(
"Symbol `{}` is being removed but has {} \
caller(s): {}. This is a breaking change.",
symbol_name,
callers.len(),
caller_list
),
});
}
_ => continue,
}
}
}
}
}
if findings.len() >= max_findings {
break;
}
}
findings.truncate(max_findings);
debug!(count = findings.len(), "breaking change scan complete");
findings
}
pub fn scan_project_index(
root: &std::path::Path,
files: &[crate::engine::scanner::FileEntry],
max_findings: usize,
skip_patterns: &[String],
) -> Vec<crate::engine::ReviewIssue> {
use crate::engine::ReviewIssue;
let mut findings = Vec::new();
let conn = match crate::index::open_global_index() {
Ok(c) => c,
Err(_) => {
debug!("no global index available — skipping project index scan");
return findings;
}
};
let project_id = match crate::index::ensure_project(&conn, root) {
Ok(id) => id,
Err(_) => {
debug!("failed to get project_id — skipping project index scan");
return findings;
}
};
let mut seen_files = std::collections::HashSet::new();
for entry in files {
if should_skip_file(&entry.path, skip_patterns) {
continue;
}
if seen_files.insert(entry.path.clone()) {
match graph::find_unused_imports(&conn, &entry.path, project_id) {
Ok(unused) => {
for u in &unused {
findings.push(ReviewIssue {
file: u.file.clone(),
line: Some(u.line),
severity: crate::engine::Severity::Minor,
issue_type: Some("index".into()),
title: format!("[index-unused-import] Unused import: {}", u.target),
body: format!(
"Import `{}` is never used in this file. \
Consider removing it to keep imports clean.",
u.target
),
suggested_fix: None,
});
}
}
Err(e) => {
debug!("unused import scan failed for {}: {}", entry.path, e);
}
}
}
if findings.len() >= max_findings {
break;
}
}
if findings.len() >= max_findings {
return findings;
}
let opts = graph::DeadCodeOptions::default();
match graph::find_dead_code(&conn, project_id, &opts) {
Ok(dead) => {
for func in dead.into_iter().take(max_findings - findings.len()) {
findings.push(ReviewIssue {
file: func.file.clone(),
line: Some(func.line),
severity: crate::engine::Severity::Info,
issue_type: Some("index".into()),
title: format!("[index-dead-code] Potentially dead code: {}", func.name),
body: format!(
"Function `{}` ({}) has no callers in the \
project. Verify it's not called via reflection, \
trait dispatch, or external entry points.",
func.name, func.kind
),
suggested_fix: None,
});
}
}
Err(e) => {
debug!("dead code scan failed: {}", e);
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
fn make_chunk(new_path: &str, content: &str) -> FileChunk {
FileChunk {
old_path: Some(new_path.to_string()),
new_path: Some(new_path.to_string()),
language: "rust".to_string(),
chunks: vec![crate::engine::diff_parser::DiffHunk {
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 0,
header: "@@ -1 +1 @@".to_string(),
lines: content
.lines()
.map(|l| crate::engine::diff_parser::DiffLine {
content: l.to_string(),
line_type: if l.starts_with('+') {
DiffLineType::Add
} else if l.starts_with('-') {
DiffLineType::Remove
} else {
DiffLineType::Context
},
old_line_no: None,
new_line_no: None,
})
.collect(),
}],
is_binary: false,
is_deleted: false,
is_new: false,
}
}
#[test]
fn scan_unused_imports_no_index_graceful() {
let chunks = vec![make_chunk("src/main.rs", "+use std::collections::HashMap;")];
let findings = scan_unused_imports(&chunks, std::path::Path::new("/nonexistent"), 10, &[]);
assert!(
findings.is_empty(),
"should gracefully return empty without index"
);
}
#[test]
fn scan_dead_code_no_index_graceful() {
let chunks = vec![make_chunk("src/main.rs", "+fn foo() {}")];
let findings =
scan_dead_code_in_review(&chunks, std::path::Path::new("/nonexistent"), 10, &[]);
assert!(
findings.is_empty(),
"should gracefully return empty without index"
);
}
#[test]
fn scan_breaking_changes_no_index_graceful() {
let chunks = vec![make_chunk("src/main.rs", "-pub fn important_api() {}")];
let findings =
scan_breaking_changes(&chunks, std::path::Path::new("/nonexistent"), 10, &[]);
assert!(
findings.is_empty(),
"should gracefully return empty without index"
);
}
#[test]
fn scan_breaking_changes_detects_removed_pub_fn() {
let chunks = vec![make_chunk(
"src/lib.rs",
"-pub fn important_api() {}\n+pub fn new_api() {}",
)];
let findings =
scan_breaking_changes(&chunks, std::path::Path::new("/nonexistent"), 10, &[]);
assert!(findings.is_empty(), "no index means no caller data");
}
#[test]
fn skip_empty_patterns() {
assert!(!should_skip_file("src/main.ts", &[]));
assert!(!should_skip_file("vitest.config.ts", &[]));
}
#[test]
fn skip_exact_match() {
let patterns = vec!["src/main.ts".into(), "src/index.ts".into()];
assert!(should_skip_file("src/main.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
}
#[test]
fn skip_wildcard_suffix() {
let patterns = vec!["*.config.ts".into()];
assert!(should_skip_file("vitest.config.ts", &patterns));
assert!(should_skip_file("vite.config.ts", &patterns));
assert!(should_skip_file("webpack.config.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
assert!(!should_skip_file("config.ts", &patterns)); }
#[test]
fn skip_wildcard_prefix() {
let patterns = vec!["vite.config.*".into()];
assert!(should_skip_file("vite.config.ts", &patterns));
assert!(should_skip_file("vite.config.js", &patterns));
assert!(!should_skip_file("webpack.config.ts", &patterns));
}
#[test]
fn skip_doublestar_prefix() {
let patterns = vec!["**/main.ts".into()];
assert!(should_skip_file("src/main.ts", &patterns));
assert!(should_skip_file("main.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
}
#[test]
fn skip_basename_exact() {
let patterns = vec!["main.ts".into()];
assert!(should_skip_file("src/main.ts", &patterns));
assert!(should_skip_file("main.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
}
#[test]
fn skip_default_list_blocks_common_entry_points() {
use crate::engine::rules::types::default_index_skip_files;
let defaults = default_index_skip_files();
assert!(should_skip_file("vitest.config.ts", &defaults));
assert!(should_skip_file("vite.config.js", &defaults));
assert!(should_skip_file("webpack.config.ts", &defaults));
assert!(should_skip_file("src/main.ts", &defaults));
assert!(should_skip_file("src/index.tsx", &defaults));
assert!(should_skip_file("src/app.tsx", &defaults));
assert!(!should_skip_file("src/lib.rs", &defaults));
assert!(!should_skip_file("src/utils.ts", &defaults));
assert!(!should_skip_file("src/components/Button.tsx", &defaults));
}
#[test]
fn skip_doublestar_dir_doublestar() {
let patterns = vec!["**/phaser/**".into()];
assert!(should_skip_file(
"src/scenes/phaser/GameScene.ts",
&patterns
));
assert!(should_skip_file("phaser/GameScene.ts", &patterns));
assert!(should_skip_file("src/phaser/utils.ts", &patterns));
assert!(!should_skip_file("src/utils/phaserHelper.ts", &patterns));
assert!(!should_skip_file("src/app.ts", &patterns));
}
#[test]
fn skip_prefix_dir_doublestar() {
let patterns = vec!["src/engine/**".into()];
assert!(should_skip_file("src/engine/physics.rs", &patterns));
assert!(should_skip_file("src/engine/core/mod.rs", &patterns));
assert!(!should_skip_file("src/app/engine.rs", &patterns));
}
#[test]
fn skip_doublestar_wildcard_ext() {
let patterns = vec!["**/*.test.ts".into()];
assert!(should_skip_file("src/utils/helpers.test.ts", &patterns));
assert!(should_skip_file("tests/integration.test.ts", &patterns));
assert!(!should_skip_file("src/utils/helpers.ts", &patterns));
assert!(!should_skip_file("src/utils/helpers.test.js", &patterns));
}
#[test]
fn skip_nested_doublestar_dir() {
let patterns = vec!["**/node_modules/**".into()];
assert!(should_skip_file("node_modules/lodash/index.js", &patterns));
assert!(should_skip_file(
"packages/app/node_modules/lodash/index.js",
&patterns
));
assert!(!should_skip_file("src/node_modulesHelper.ts", &patterns));
}
#[test]
fn skip_multiple_patterns_any_match() {
let patterns = vec![
"**/phaser/**".into(),
"*.config.ts".into(),
"src/main.ts".into(),
];
assert!(should_skip_file(
"src/scenes/phaser/GameScene.ts",
&patterns
));
assert!(should_skip_file("vite.config.ts", &patterns));
assert!(should_skip_file("src/main.ts", &patterns));
assert!(!should_skip_file("src/lib.rs", &patterns));
}
}