use log::{debug, warn};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::{
error::{IndexerError, Result},
utils::{chunk_text, detect_file_type, normalize_path, should_ignore_file, FileType},
};
#[derive(Debug, Clone)]
pub struct FileInfo {
pub path: String,
pub size: u64,
pub modified_time: u64,
pub hash: String,
pub parent_dirs: Vec<String>,
pub content: Option<String>,
pub errors: Option<String>,
}
pub struct FileScanner {
max_file_size: u64,
ignore_patterns: Vec<String>,
}
impl Default for FileScanner {
fn default() -> Self {
Self::new()
}
}
impl FileScanner {
pub fn new() -> Self {
Self {
max_file_size: 10 * 1024 * 1024, ignore_patterns: vec![
".git".to_string(),
"node_modules".to_string(),
"target".to_string(),
],
}
}
pub fn with_ignore_patterns(ignore_patterns: Vec<String>) -> Self {
Self {
max_file_size: 10 * 1024 * 1024,
ignore_patterns,
}
}
pub fn with_max_size(max_size: u64) -> Self {
Self {
max_file_size: max_size,
ignore_patterns: vec![],
}
}
pub async fn scan_directory(&self, dir_path: &Path) -> Result<Vec<FileInfo>> {
let mut files = Vec::new();
for entry in WalkDir::new(dir_path).follow_links(false) {
let entry = entry.map_err(|e| {
IndexerError::file_processing(format!("Error walking directory: {e}"))
})?;
let path = entry.path();
if path.is_dir() {
continue;
}
if should_ignore_file(path, &self.ignore_patterns) {
debug!("Ignoring file due to patterns: {path:?}");
continue;
}
let metadata = tokio::fs::metadata(path).await?;
let size = metadata.len();
let modified_time = metadata
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| IndexerError::file_processing(format!("Invalid modified time: {e}")))?
.as_secs();
let hash = crate::utils::calculate_file_hash(path)?;
let parent_dirs = self.extract_parent_directories(path, dir_path);
let (content, errors) = if size > self.max_file_size {
(
None,
Some(format!(
"File too large: {size} bytes (max: {})",
self.max_file_size
)),
)
} else {
match tokio::fs::read_to_string(path).await {
Ok(content) => (Some(content), None),
Err(e) => (None, Some(format!("Failed to read file: {e}"))),
}
};
let normalized_path = match normalize_path(path) {
Ok(p) => p,
Err(e) => {
warn!("Failed to normalize path {path:?}: {e}");
path.to_string_lossy().to_string()
}
};
files.push(FileInfo {
path: normalized_path,
size,
modified_time,
hash,
parent_dirs,
content,
errors,
});
}
Ok(files)
}
fn extract_parent_directories(&self, file_path: &Path, root_dir: &Path) -> Vec<String> {
let mut parent_dirs = Vec::new();
if let Ok(normalized_root) = normalize_path(root_dir) {
parent_dirs.push(normalized_root);
}
if let Ok(relative_path) = file_path.strip_prefix(root_dir) {
let mut current = root_dir.to_path_buf();
for component in relative_path.parent().unwrap_or(Path::new("")).components() {
current = current.join(component);
if let Ok(normalized_current) = normalize_path(¤t) {
parent_dirs.push(normalized_current);
}
}
}
parent_dirs
}
}
pub struct FileProcessor {
max_file_size: u64,
ignore_patterns: Vec<String>,
chunk_size: usize,
overlap: usize,
}
#[derive(Debug, Clone)]
pub struct ProcessedFile {
pub path: PathBuf,
pub content: String,
pub chunks: Vec<String>,
pub file_type: Option<FileType>,
pub size: u64,
pub hash: String,
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub path: PathBuf,
pub size: u64,
pub modified_time: u64,
pub file_type: Option<FileType>,
}
impl FileProcessor {
pub fn new(
max_file_size: u64,
ignore_patterns: Vec<String>,
chunk_size: usize,
overlap: usize,
) -> Self {
Self {
max_file_size,
ignore_patterns,
chunk_size,
overlap,
}
}
pub async fn walk_directory(&self, dir_path: &Path) -> Result<Vec<FileMetadata>> {
let mut files = Vec::new();
for entry in WalkDir::new(dir_path).follow_links(false) {
let entry = entry.map_err(|e| {
IndexerError::file_processing(format!("Error walking directory: {e}"))
})?;
let path = entry.path();
if path.is_dir() {
continue;
}
if should_ignore_file(path, &self.ignore_patterns) {
debug!("Ignoring file due to patterns: {path:?}");
continue;
}
let metadata = tokio::fs::metadata(path).await?;
let size = metadata.len();
if size > self.max_file_size {
warn!("Skipping large file ({size} bytes): {path:?}");
continue;
}
let modified_time = metadata
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| IndexerError::file_processing(format!("Invalid modified time: {e}")))?
.as_secs();
let file_type = detect_file_type(path);
files.push(FileMetadata {
path: path.to_path_buf(),
size,
modified_time,
file_type,
});
}
Ok(files)
}
pub async fn process_file(&self, path: &Path) -> Result<ProcessedFile> {
debug!("Processing file: {path:?}");
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| IndexerError::file_processing(format!("Failed to read file: {e}")))?;
let chunks = chunk_text(&content, self.chunk_size, self.overlap);
let metadata = tokio::fs::metadata(path).await?;
let size = metadata.len();
let file_type = detect_file_type(path);
let hash = crate::utils::calculate_file_hash(path)?;
Ok(ProcessedFile {
path: path.to_path_buf(),
content,
chunks,
file_type,
size,
hash,
})
}
pub fn should_process_file(&self, file_type: &Option<FileType>) -> bool {
match file_type {
Some(FileType::Text)
| Some(FileType::Code)
| Some(FileType::Data)
| Some(FileType::Markup)
| Some(FileType::Config) => true,
None => false,
}
}
pub fn extract_parent_directories(
&self,
file_path: &Path,
root_dirs: &[PathBuf],
) -> Vec<String> {
let mut parent_dirs = Vec::new();
for root in root_dirs {
if let Ok(relative_path) = file_path.strip_prefix(root) {
if let Some(parent) = relative_path.parent() {
if let Ok(normalized_parent) = normalize_path(root.join(parent)) {
parent_dirs.push(normalized_parent);
}
}
if let Ok(normalized_root) = normalize_path(root) {
parent_dirs.push(normalized_root);
}
break;
}
}
parent_dirs
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_walk_directory() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
fs::write(temp_path.join("test.txt"), "test content").unwrap();
fs::write(temp_path.join("test.md"), "# Test").unwrap();
let processor = FileProcessor::new(1024 * 1024, vec![], 512, 50);
let files = processor.walk_directory(temp_path).await.unwrap();
assert_eq!(files.len(), 2);
}
#[tokio::test]
async fn test_process_file() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let file_path = temp_path.join("test.txt");
fs::write(
&file_path,
"This is a test file content that should be chunked.",
)
.unwrap();
let processor = FileProcessor::new(1024 * 1024, vec![], 20, 5);
let processed = processor.process_file(&file_path).await.unwrap();
assert!(!processed.content.is_empty());
assert!(!processed.chunks.is_empty());
assert_eq!(processed.file_type, Some(FileType::Text));
}
}