use crate::{
models::{InputConfig, OutputConfig, ProcessedFile, ProcessingConfig},
pipeline::ProcessingContext,
};
use anyhow::{anyhow, Result};
use content_inspector::{inspect, ContentType};
use ignore::gitignore::GitignoreBuilder;
use path_slash::PathBufExt;
use rayon::prelude::*;
use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
time::Instant,
};
use tracing::debug;
pub struct ParallelFileProcessor {
context: Arc<ProcessingContext>,
file_counter: Arc<Mutex<HashMap<i32, usize>>>,
}
impl ParallelFileProcessor {
pub fn new(context: ProcessingContext) -> Self {
Self {
context: Arc::new(context),
file_counter: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn process_files_parallel(&self, base_path: &Path) -> Result<Vec<ProcessedFile>> {
let start_time = Instant::now();
let mut all_processed_files = Vec::new();
let expanded_paths = self.expand_globs(base_path)?;
let base_dir = self.determine_base_dir(base_path);
for path in expanded_paths {
if self.context.file_system.is_file(&path) {
let files = self.process_single_file(&path, &base_dir)?;
all_processed_files.extend(files);
} else if self.context.file_system.is_directory(&path) {
let files = self.process_directory(&path, &base_dir)?;
all_processed_files.extend(files);
}
}
all_processed_files.par_sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.file_index.cmp(&b.file_index))
});
if self.context.processing_config.debug {
debug!(
"Processed {} files in parallel for base_path: {} in {:?}",
all_processed_files.len(),
base_path.display(),
start_time.elapsed()
);
}
Ok(all_processed_files)
}
fn expand_globs(&self, base_path: &Path) -> Result<Vec<std::path::PathBuf>> {
let mut expanded_paths = Vec::new();
let path_str = base_path.to_string_lossy();
for entry in glob::glob(&path_str)? {
match entry {
Ok(path) => {
let resolved_path = if self.context.file_system.is_symlink(&path) {
self.context
.file_system
.resolve_symlink(&path)
.unwrap_or(path)
} else {
path
};
expanded_paths.push(resolved_path);
}
Err(e) => debug!("Glob entry error: {:?}", e),
}
}
Ok(expanded_paths)
}
fn determine_base_dir(&self, base_path: &Path) -> std::path::PathBuf {
if let Some(shared_base_dir) = self.calculate_shared_base_dir() {
return shared_base_dir;
}
let path_str = base_path.to_string_lossy();
if Self::is_glob_pattern(&path_str) {
std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf())
} else if base_path.is_file() {
base_path.parent().unwrap_or(Path::new(".")).to_path_buf()
} else {
base_path.to_path_buf()
}
}
fn calculate_shared_base_dir(&self) -> Option<std::path::PathBuf> {
let input_paths = &self.context.input_config.input_paths;
if input_paths.len() <= 1 {
return None;
}
let current_dir = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
input_paths
.iter()
.map(|input_path| Self::resolve_input_base(input_path, ¤t_dir))
.reduce(|base, path| Self::find_common_base(&base, &path))
}
fn resolve_input_base(input_path: &str, current_dir: &Path) -> std::path::PathBuf {
let path = Path::new(input_path);
if Self::is_glob_pattern(input_path) {
let wildcard_index = input_path
.char_indices()
.find(|(_, c)| matches!(c, '*' | '?' | '['))
.map(|(i, _)| i)
.unwrap_or(input_path.len());
let prefix = &input_path[..wildcard_index];
let prefix_path = Path::new(prefix);
let base_dir = prefix_path.parent().unwrap_or(Path::new(""));
if base_dir.as_os_str().is_empty() {
current_dir.to_path_buf()
} else if base_dir.is_absolute() {
base_dir.to_path_buf()
} else {
current_dir.join(base_dir)
}
} else if path.is_absolute() {
path.to_path_buf()
} else {
current_dir.join(path)
}
}
fn is_glob_pattern(path: &str) -> bool {
path.contains('*') || path.contains('?') || path.contains('[')
}
fn find_common_base(path1: &Path, path2: &Path) -> std::path::PathBuf {
let mut common_base = std::path::PathBuf::new();
for (component1, component2) in path1.components().zip(path2.components()) {
if component1 != component2 {
break;
}
common_base.push(component1.as_os_str());
}
if common_base.as_os_str().is_empty() {
Path::new(".").to_path_buf()
} else {
common_base
}
}
fn process_single_file(&self, file_path: &Path, base_dir: &Path) -> Result<Vec<ProcessedFile>> {
let rel_path = self.normalize_path(file_path, base_dir);
if self.should_ignore_file(file_path, &rel_path) {
debug!("Skipping ignored file: {rel_path}");
return Ok(Vec::new());
}
match self.context.file_system.read_file(file_path) {
Ok(content) => {
if inspect(&content) == ContentType::BINARY {
debug!("Skipping binary file: {rel_path}");
Ok(Vec::new())
} else {
let processed_file = self.create_processed_file(&rel_path, &content)?;
Ok(vec![processed_file])
}
}
Err(e) => {
debug!("Failed to read {rel_path}: {e}");
Ok(Vec::new())
}
}
}
fn process_directory(&self, dir_path: &Path, base_dir: &Path) -> Result<Vec<ProcessedFile>> {
let mut processed_files = Vec::new();
let gitignore = self.build_gitignore(dir_path)?;
let files_to_process: Vec<_> =
self.collect_files_to_process(dir_path, base_dir, &gitignore)?;
let results: Vec<Result<ProcessedFile>> = files_to_process
.par_iter()
.map(|(path, rel_path)| self.process_file_with_priority(path, rel_path, base_dir))
.collect();
processed_files.extend(results.into_iter().filter_map(|r| r.ok()));
Ok(processed_files)
}
fn collect_files_to_process(
&self,
dir_path: &Path,
base_dir: &Path,
gitignore: &Arc<ignore::gitignore::Gitignore>,
) -> Result<Vec<(std::path::PathBuf, String)>> {
let mut files_to_process = Vec::new();
let mut walk_builder = ignore::WalkBuilder::new(dir_path);
walk_builder
.follow_links(false)
.standard_filters(true)
.require_git(false);
let gitignore = Arc::clone(gitignore);
for result in walk_builder.build() {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path().to_path_buf();
let rel_path = self.normalize_path(&path, base_dir);
if gitignore.matched(&path, false).is_ignore() {
debug!("Skipping ignored file: {rel_path}");
continue;
}
files_to_process.push((path, rel_path));
}
Ok(files_to_process)
}
fn process_file_with_priority(
&self,
file_path: &Path,
rel_path: &str,
_base_dir: &Path,
) -> Result<ProcessedFile> {
let content = self.context.file_system.read_file(file_path)?;
if inspect(&content) == ContentType::BINARY {
return Err(anyhow!("Binary file: {}", rel_path));
}
let (priority, category) = self.calculate_priority_with_category(rel_path);
let file_index = self.get_next_file_index(priority);
Ok(ProcessedFile::new_with_category(
rel_path.to_string(),
String::from_utf8_lossy(&content).to_string(),
priority,
file_index,
category,
))
}
#[allow(dead_code)]
fn calculate_priority(&self, rel_path: &str) -> i32 {
let mut priority = 0;
for rule in &self.context.processing_config.priority_rules {
if let Ok(regex) = regex::Regex::new(&rule.pattern) {
if regex.is_match(rel_path) {
priority += rule.score;
}
}
}
if let Some(commit_time) = self.context.repository_info.commit_times.get(rel_path) {
let max_boost = self.context.input_config.git_boost_max.unwrap_or(100);
priority += self.calculate_git_boost(
*commit_time,
&self.context.repository_info.commit_times,
max_boost,
);
}
priority
}
fn calculate_priority_with_category(
&self,
rel_path: &str,
) -> (i32, crate::category::FileCategory) {
use crate::priority::get_file_priority_with_category;
let (mut priority, category) = get_file_priority_with_category(
rel_path,
&self.context.processing_config.priority_rules,
&self.context.processing_config.category_weights,
);
if let Some(commit_time) = self.context.repository_info.commit_times.get(rel_path) {
let max_boost = self.context.input_config.git_boost_max.unwrap_or(100);
priority += self.calculate_git_boost(
*commit_time,
&self.context.repository_info.commit_times,
max_boost,
);
}
(priority, category)
}
fn calculate_git_boost(
&self,
file_time: u64,
all_times: &HashMap<String, u64>,
max_boost: i32,
) -> i32 {
if all_times.is_empty() {
return 0;
}
let times: Vec<&u64> = all_times.values().collect();
let min_time = times.iter().min().map_or(file_time, |&&t| t);
let max_time = times.iter().max().map_or(file_time, |&&t| t);
if max_time == min_time {
return 0; }
let normalized = (file_time - min_time) as f64 / (max_time - min_time) as f64;
(normalized * max_boost as f64).round() as i32
}
fn get_next_file_index(&self, priority: i32) -> usize {
let mut counter = self.file_counter.lock().unwrap();
let next_index = counter.entry(priority).or_insert(0);
let index = *next_index;
*next_index += 1;
index
}
fn should_ignore_file(&self, file_path: &Path, _rel_path: &str) -> bool {
let path_str = file_path.to_string_lossy();
let ignored_by_pattern = self
.context
.input_config
.ignore_patterns
.iter()
.any(|pattern| pattern.matches(&path_str));
let is_binary = file_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| self.context.input_config.binary_extensions.contains(ext))
.unwrap_or(false);
ignored_by_pattern || is_binary
}
fn build_gitignore(&self, dir_path: &Path) -> Result<Arc<ignore::gitignore::Gitignore>> {
let mut gitignore_builder = GitignoreBuilder::new(dir_path);
for pattern in &self.context.input_config.ignore_patterns {
gitignore_builder.add_line(None, &pattern.to_string())?;
}
let gitignore_file = dir_path.join(".gitignore");
if self.context.file_system.path_exists(&gitignore_file) {
gitignore_builder.add(&gitignore_file);
}
Ok(Arc::new(gitignore_builder.build()?))
}
fn create_processed_file(&self, rel_path: &str, content: &[u8]) -> Result<ProcessedFile> {
let (priority, category) = self.calculate_priority_with_category(rel_path);
let file_index = self.get_next_file_index(priority);
Ok(ProcessedFile::new_with_category(
rel_path.to_string(),
String::from_utf8_lossy(content).to_string(),
priority,
file_index,
category,
))
}
fn normalize_path(&self, path: &Path, base: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.to_path_buf()
.to_slash()
.unwrap_or_default()
.to_string()
}
}
pub fn normalize_path(path: &Path, base: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.to_path_buf()
.to_slash()
.unwrap_or_default()
.to_string()
}
pub fn process_files_parallel(
base_path: &Path,
config: &crate::config::YekConfig,
_boost_map: &HashMap<String, i32>,
) -> Result<Vec<ProcessedFile>> {
let processor = ParallelFileProcessor::new(ProcessingContext::new(
InputConfig {
input_paths: config.input_paths.clone(),
ignore_patterns: config
.ignore_patterns
.iter()
.map(|s| glob::Pattern::new(s).unwrap())
.collect(),
binary_extensions: config.binary_extensions.iter().cloned().collect(),
max_git_depth: config.max_git_depth,
git_boost_max: config.git_boost_max,
},
OutputConfig::default(), ProcessingConfig {
priority_rules: config.priority_rules.clone(),
category_weights: config.category_weights.clone().unwrap_or_default(),
debug: config.debug,
parallel: true,
max_threads: None,
memory_limit_mb: None,
batch_size: 1000,
},
crate::models::RepositoryInfo::new(base_path.to_path_buf(), false), Arc::new(crate::repository::RealFileSystem),
));
processor.process_files_parallel(base_path)
}