use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::{Walk, WalkBuilder};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use tracing::{info, warn};
use crate::analyzers::registry::RegistryHandle;
use crate::internal::config::FileScanningConfig;
#[derive(Clone)]
pub struct FileFilters {
include_globs: GlobSet,
exclude_globs: GlobSet,
max_file_size: u64,
}
#[derive(Clone)]
pub struct LanguageDetector {
rust_extensions: GlobSet,
python_extensions: GlobSet,
typescript_extensions: GlobSet,
javascript_extensions: GlobSet,
go_extensions: GlobSet,
}
#[derive(Debug, Clone)]
pub struct ScanConfig {
pub follow_symlinks: bool,
pub max_depth: Option<u32>,
pub parallel: bool,
}
pub struct ScanResult {
pub root: PathBuf,
pub files: Vec<DiscoveredFile>,
pub total_files_found: usize,
pub total_files_filtered: usize,
pub scan_duration: std::time::Duration,
pub languages_found: std::collections::HashMap<String, usize>,
}
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub path: PathBuf,
pub language: String,
pub size: u64,
pub relative_path: PathBuf,
}
impl DiscoveredFile {
pub fn index_path(&self) -> crate::storage::graph::IndexPath {
crate::storage::graph::IndexPath::new(&self.relative_path.to_string_lossy())
}
}
pub fn canonical_root<P: AsRef<Path>>(path: P) -> PathBuf {
let path = path.as_ref();
std::fs::canonicalize(path).unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
})
}
#[derive(Clone)]
pub struct RepositoryScanner {
filters: FileFilters,
language_detector: LanguageDetector,
config: ScanConfig,
scanning_config: FileScanningConfig,
registry: Option<RegistryHandle>,
}
impl FileFilters {
pub fn new(config: &FileScanningConfig) -> Result<Self> {
let mut include_builder = GlobSetBuilder::new();
for pattern in &config.include_patterns {
let glob = Glob::new(pattern)
.with_context(|| format!("Invalid include pattern: {}", pattern))?;
include_builder.add(glob);
}
let mut exclude_builder = GlobSetBuilder::new();
for pattern in &config.exclude_patterns {
let glob = Glob::new(pattern)
.with_context(|| format!("Invalid exclude pattern: {}", pattern))?;
exclude_builder.add(glob);
}
Ok(Self {
include_globs: include_builder.build()?,
exclude_globs: exclude_builder.build()?,
max_file_size: config.max_file_size,
})
}
pub fn should_include(&self, path: &Path, size: u64) -> bool {
if size > self.max_file_size {
return false;
}
let path_str = path.to_string_lossy().to_string();
if self.exclude_globs.is_match(&path_str) {
return false;
}
if self.include_globs.len() > 0 {
self.include_globs.is_match(&path_str)
} else {
true
}
}
}
impl LanguageDetector {
pub fn new() -> Result<Self> {
let rust_globs = Self::build_globset(&["*.rs"])?;
let python_globs = Self::build_globset(&["*.py", "*.pyi"])?;
let typescript_globs = Self::build_globset(&["*.ts", "*.tsx"])?;
let javascript_globs = Self::build_globset(&["*.js", "*.jsx", "*.mjs", "*.cjs"])?;
let go_globs = Self::build_globset(&["*.go"])?;
Ok(Self {
rust_extensions: rust_globs,
python_extensions: python_globs,
typescript_extensions: typescript_globs,
javascript_extensions: javascript_globs,
go_extensions: go_globs,
})
}
fn build_globset(patterns: &[&str]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(Glob::new(pattern)?);
}
Ok(builder.build()?)
}
pub fn detect_language(&self, path: &Path) -> String {
let path_str = path.to_string_lossy().to_string();
if self.rust_extensions.is_match(&path_str) {
"rust".to_string()
} else if self.python_extensions.is_match(&path_str) {
"python".to_string()
} else if self.typescript_extensions.is_match(&path_str) {
"typescript".to_string()
} else if self.javascript_extensions.is_match(&path_str) {
"javascript".to_string()
} else if self.go_extensions.is_match(&path_str) {
"go".to_string()
} else {
"unknown".to_string()
}
}
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
follow_symlinks: false,
max_depth: Some(20),
parallel: true,
}
}
}
impl RepositoryScanner {
pub fn new(
scanning_config: &FileScanningConfig,
scan_config: Option<ScanConfig>,
) -> Result<Self> {
let filters = FileFilters::new(scanning_config)?;
let language_detector = LanguageDetector::new()?;
let config = scan_config.unwrap_or_default();
Ok(Self {
filters,
language_detector,
config,
scanning_config: scanning_config.clone(),
registry: None,
})
}
pub fn new_with_registry(
scanning_config: &FileScanningConfig,
registry: RegistryHandle,
scan_config: Option<ScanConfig>,
) -> Result<Self> {
let mut scanner = Self::new(scanning_config, scan_config)?;
scanner.registry = Some(registry);
Ok(scanner)
}
fn resolve_language(&self, path: &Path) -> String {
if let Some(registry) = &self.registry {
if let Some(label) = registry.detect_language(&path.to_string_lossy()) {
return label;
}
}
self.language_detector.detect_language(path)
}
pub fn scan<P: AsRef<Path>>(&self, root_path: P) -> Result<ScanResult> {
let start_time = Instant::now();
let root_path = canonical_root(root_path);
let root_path = root_path.as_path();
info!("Starting repository scan at: {:?}", root_path);
let walker = self.build_walker(root_path)?;
let total_found = Arc::new(AtomicUsize::new(0));
let total_filtered = Arc::new(AtomicUsize::new(0));
let mut discovered_files = Vec::new();
let mut languages_found = std::collections::HashMap::new();
for result in walker {
match result {
Ok(entry) => {
total_found.fetch_add(1, Ordering::Relaxed);
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
continue;
}
let path = entry.path();
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(e) => {
warn!("Failed to get metadata for {:?}: {}", path, e);
continue;
}
};
let file_size = metadata.len();
if !self.filters.should_include(path, file_size) {
total_filtered.fetch_add(1, Ordering::Relaxed);
continue;
}
let language = self.resolve_language(path);
if language == "unknown" {
continue;
}
let relative_path = path.strip_prefix(root_path).unwrap_or(path).to_path_buf();
let discovered_file = DiscoveredFile {
path: path.to_path_buf(),
language: language.clone(),
size: file_size,
relative_path,
};
discovered_files.push(discovered_file);
*languages_found.entry(language).or_insert(0) += 1;
}
Err(e) => {
warn!("Error walking directory: {}", e);
}
}
}
let scan_duration = start_time.elapsed();
info!(
"Repository scan completed in {:?}. Found {} files, filtered out {}",
scan_duration,
discovered_files.len(),
total_filtered.load(Ordering::Relaxed)
);
Ok(ScanResult {
root: root_path.to_path_buf(),
files: discovered_files,
total_files_found: total_found.load(Ordering::Relaxed),
total_files_filtered: total_filtered.load(Ordering::Relaxed),
scan_duration,
languages_found,
})
}
fn build_walker(&self, root_path: &Path) -> Result<Walk> {
let mut builder = WalkBuilder::new(root_path);
builder
.follow_links(self.scanning_config.follow_symlinks)
.git_ignore(self.scanning_config.respect_gitignore)
.git_global(self.scanning_config.respect_gitignore)
.git_exclude(self.scanning_config.respect_gitignore)
.hidden(false);
if let Some(max_depth) = self.scanning_config.max_depth {
builder.max_depth(Some(max_depth as usize));
}
if self.config.parallel {
builder.threads(num_cpus::get());
} else {
builder.threads(1);
}
Ok(builder.build())
}
pub fn quick_scan<P: AsRef<Path>>(
&self,
root_path: P,
) -> Result<(usize, std::collections::HashMap<String, usize>)> {
let root_path = canonical_root(root_path);
let walker = self.build_walker(&root_path)?;
let mut count = 0;
let mut languages = std::collections::HashMap::new();
for result in walker {
if let Ok(entry) = result {
if entry.file_type().map_or(false, |ft| ft.is_file()) {
let path = entry.path();
if let Ok(metadata) = entry.metadata() {
if self.filters.should_include(path, metadata.len()) {
let language = self.resolve_language(path);
if language != "unknown" {
count += 1;
*languages.entry(language).or_insert(0) += 1;
}
}
}
}
}
}
Ok((count, languages))
}
pub fn should_analyze(&self, path: &Path) -> Result<bool> {
let metadata = std::fs::metadata(path)
.with_context(|| format!("Failed to get metadata for {:?}", path))?;
let file_size = metadata.len();
Ok(self.filters.should_include(path, file_size))
}
pub fn detect_file_language(&self, path: &Path) -> String {
self.resolve_language(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_config() -> FileScanningConfig {
FileScanningConfig {
include_patterns: vec!["*.rs".to_string(), "*.py".to_string()],
exclude_patterns: vec!["**/target/**".to_string(), "*.test.rs".to_string()],
max_file_size: 1024 * 1024, follow_symlinks: false,
max_depth: Some(10),
respect_gitignore: true,
}
}
#[test]
fn test_file_filters_creation() {
let config = create_test_config();
let filters = FileFilters::new(&config).unwrap();
assert!(filters.should_include(Path::new("main.rs"), 1000));
assert!(filters.should_include(Path::new("script.py"), 1000));
assert!(!filters.should_include(Path::new("main.js"), 1000));
assert!(!filters.should_include(Path::new("target/debug/main.rs"), 1000));
assert!(!filters.should_include(Path::new("main.test.rs"), 1000));
assert!(!filters.should_include(Path::new("huge.rs"), 2 * 1024 * 1024));
}
#[test]
fn test_language_detector() {
let detector = LanguageDetector::new().unwrap();
assert_eq!(detector.detect_language(Path::new("main.rs")), "rust");
assert_eq!(detector.detect_language(Path::new("script.py")), "python");
assert_eq!(detector.detect_language(Path::new("app.ts")), "typescript");
assert_eq!(detector.detect_language(Path::new("app.js")), "javascript");
assert_eq!(detector.detect_language(Path::new("main.go")), "go");
assert_eq!(
detector.detect_language(Path::new("unknown.txt")),
"unknown"
);
}
#[test]
fn test_resolve_language_falls_back_for_unregistered_known_language() -> Result<()> {
use crate::analyzers::LanguageAnalyzerRegistry;
use crate::analyzers::registry::{DefaultLanguageRegistry, RegistryHandle};
use crate::analyzers::rust::RustAnalyzer;
let mut registry = DefaultLanguageRegistry::new();
registry.register(Box::new(RustAnalyzer::new().unwrap()))?;
let handle = RegistryHandle::new(®istry);
let config = create_test_config();
let scanner = RepositoryScanner::new_with_registry(&config, handle, None)?;
assert_eq!(scanner.detect_file_language(Path::new("main.rs")), "rust");
assert_eq!(
scanner.detect_file_language(Path::new("script.py")),
"python"
);
assert_eq!(
scanner.detect_file_language(Path::new("readme.txt")),
"unknown"
);
Ok(())
}
#[test]
fn test_repository_scanner() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
fs::write(root.join("main.rs"), "fn main() {}")?;
fs::write(root.join("lib.rs"), "pub fn test() {}")?;
fs::write(root.join("script.py"), "print('hello')")?;
fs::write(root.join("readme.txt"), "This is a readme")?;
fs::create_dir(root.join("src"))?;
fs::write(root.join("src/parser.rs"), "pub mod parser;")?;
fs::create_dir(root.join("target"))?;
fs::write(root.join("target/main.rs"), "// generated")?;
let config = create_test_config();
let scan_config = ScanConfig {
follow_symlinks: false,
..Default::default()
};
let scanner = RepositoryScanner::new(&config, Some(scan_config))?;
let result = scanner.scan(root)?;
assert_eq!(result.files.len(), 4);
assert_eq!(result.languages_found.get("rust"), Some(&3));
assert_eq!(result.languages_found.get("python"), Some(&1));
Ok(())
}
#[test]
fn every_spelling_of_one_root_yields_identical_identities() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = std::fs::canonicalize(temp_dir.path())?;
fs::create_dir(root.join("src"))?;
fs::write(root.join("src/main.rs"), "fn main() {}")?;
fs::write(root.join("lib.rs"), "pub fn f() {}")?;
let scanner = RepositoryScanner::new(&create_test_config(), None)?;
let ids = |p: &Path| -> Result<Vec<String>> {
let result = scanner.scan(p)?;
assert_eq!(result.root, root, "root must canonicalize to one value");
let mut v: Vec<String> = result
.files
.iter()
.map(|f| f.index_path().into_string())
.collect();
v.sort();
Ok(v)
};
let absolute = ids(&root)?;
let climbed = ids(&root.join("src").join(".."))?;
let dotted = ids(&PathBuf::from(format!("{}/./.", root.display())))?;
assert_eq!(absolute, vec!["lib.rs", "src/main.rs"]);
assert_eq!(absolute, climbed);
assert_eq!(absolute, dotted);
Ok(())
}
#[test]
#[cfg(unix)]
fn symlinked_root_and_real_root_agree() -> Result<()> {
let temp_dir = TempDir::new()?;
let real = temp_dir.path().join("real");
fs::create_dir(&real)?;
fs::write(real.join("a.rs"), "pub fn a() {}")?;
let link = temp_dir.path().join("link");
std::os::unix::fs::symlink(&real, &link)?;
let scanner = RepositoryScanner::new(&create_test_config(), None)?;
let via_real = scanner.scan(&real)?;
let via_link = scanner.scan(&link)?;
assert_eq!(via_real.root, via_link.root);
assert_eq!(
via_real.files[0].index_path(),
via_link.files[0].index_path()
);
Ok(())
}
}