use anyhow::Result;
use indicatif::{ProgressBar, ProgressState, ProgressStyle};
use std::fmt::Write;
use std::path::Path;
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
const MAX_FILE_COUNT: usize = 10_000_000; const MAX_CHUNKS_PER_FILE: usize = 100_000; const MAX_EMBEDDINGS_PER_FILE: usize = 100_000;
pub struct IndexingProgress {
start_time: Instant,
discovery_spinner: Option<ProgressBar>,
processing_bar: Option<ProgressBar>,
stats: IndexingStats,
enabled: bool,
}
#[derive(Debug, Clone, Default)]
pub struct IndexingStats {
pub files_discovered: usize,
pub files_processed: usize,
pub files_failed: usize,
pub chunks_created: usize,
pub embeddings_generated: usize,
pub failed_files: Vec<String>,
}
impl IndexingStats {
pub fn validate(&self) -> Result<()> {
if self.files_processed + self.files_failed > self.files_discovered {
anyhow::bail!(
"Invalid statistics: processed ({}) + failed ({}) exceeds discovered ({})",
self.files_processed,
self.files_failed,
self.files_discovered
);
}
if self.files_discovered > MAX_FILE_COUNT {
anyhow::bail!(
"Files discovered {} exceeds maximum limit {}",
self.files_discovered,
MAX_FILE_COUNT
);
}
if self.files_processed > 0 {
let avg_chunks_per_file = self.chunks_created / self.files_processed;
if avg_chunks_per_file > MAX_CHUNKS_PER_FILE {
warn!(
"Average chunks per file ({}) is very high, may indicate an issue",
avg_chunks_per_file
);
}
let avg_embeddings_per_file = self.embeddings_generated / self.files_processed;
if avg_embeddings_per_file > MAX_EMBEDDINGS_PER_FILE {
warn!(
"Average embeddings per file ({}) is very high, may indicate an issue",
avg_embeddings_per_file
);
}
}
Ok(())
}
pub fn total_attempted_files(&self) -> usize {
self.files_processed + self.files_failed
}
pub fn success_rate(&self) -> f64 {
let total = self.total_attempted_files();
if total == 0 {
100.0
} else {
(self.files_processed as f64 / total as f64) * 100.0
}
}
}
impl IndexingProgress {
pub fn new(enabled: bool) -> Self {
Self {
start_time: Instant::now(),
discovery_spinner: None,
processing_bar: None,
stats: IndexingStats::default(),
enabled,
}
}
pub fn start_discovery(&mut self) -> Result<()> {
if !self.enabled {
info!("Starting file discovery...");
return Ok(());
}
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.green} {msg}")?
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.set_message("Discovering files...");
spinner.enable_steady_tick(Duration::from_millis(100));
self.discovery_spinner = Some(spinner);
Ok(())
}
pub fn finish_discovery(&mut self, file_count: usize) -> Result<()> {
if file_count > MAX_FILE_COUNT {
warn!(
"File count {} exceeds maximum limit {}, clamping to limit",
file_count, MAX_FILE_COUNT
);
self.stats.files_discovered = MAX_FILE_COUNT;
} else {
self.stats.files_discovered = file_count;
}
if let Some(spinner) = &self.discovery_spinner {
spinner.finish_with_message(format!("✓ Found {} files", file_count));
} else {
info!("Found {} files", file_count);
}
self.discovery_spinner = None;
Ok(())
}
pub fn start_processing(&mut self, total_files: usize) -> Result<()> {
let validated_total_files = if total_files > MAX_FILE_COUNT {
warn!(
"Total files count {} exceeds maximum limit {}, clamping to limit",
total_files, MAX_FILE_COUNT
);
MAX_FILE_COUNT
} else {
total_files
};
if !self.enabled {
info!(
"Starting file processing ({} files)...",
validated_total_files
);
return Ok(());
}
let bar = ProgressBar::new(validated_total_files as u64);
bar.set_style(
ProgressStyle::with_template(
"Processing files... [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} files {msg}"
)?
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
})
.progress_chars("█▉▊▋▌▍▎▏ "),
);
self.processing_bar = Some(bar);
Ok(())
}
pub fn update_processing(&mut self, current_file: &Path) -> Result<()> {
if let Some(bar) = &self.processing_bar {
let file_name = current_file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<unknown>");
bar.set_message(format!("({})", file_name));
bar.inc(1);
}
Ok(())
}
pub fn record_file_success(&mut self, chunks_created: usize, embeddings_generated: usize) {
let validated_chunks = if chunks_created > MAX_CHUNKS_PER_FILE {
warn!(
"Chunks created {} exceeds maximum limit {} for a single file, clamping to limit",
chunks_created, MAX_CHUNKS_PER_FILE
);
MAX_CHUNKS_PER_FILE
} else {
chunks_created
};
let validated_embeddings = if embeddings_generated > MAX_EMBEDDINGS_PER_FILE {
warn!("Embeddings generated {} exceeds maximum limit {} for a single file, clamping to limit",
embeddings_generated, MAX_EMBEDDINGS_PER_FILE);
MAX_EMBEDDINGS_PER_FILE
} else {
embeddings_generated
};
self.stats.files_processed = self.stats.files_processed.saturating_add(1);
self.stats.chunks_created = self.stats.chunks_created.saturating_add(validated_chunks);
self.stats.embeddings_generated = self
.stats
.embeddings_generated
.saturating_add(validated_embeddings);
}
pub fn record_file_failure(&mut self, file_path: &Path, error: &anyhow::Error) {
self.stats.files_failed = self.stats.files_failed.saturating_add(1);
self.stats
.failed_files
.push(format!("{}: {}", file_path.display(), error));
debug!(
"File processing failed: {} - {}",
file_path.display(),
error
);
}
pub fn finish_processing(&mut self, index_path: &Path) -> Result<()> {
if let Some(bar) = &self.processing_bar {
bar.finish_and_clear();
}
self.processing_bar = None;
self.show_summary(index_path)?;
Ok(())
}
pub fn show_summary(&self, index_path: &Path) -> Result<()> {
let duration = self.start_time.elapsed();
let duration_str = humantime::format_duration(duration);
println!(
"Generated {} chunks from {} files in {}",
self.stats.chunks_created, self.stats.files_processed, duration_str
);
if self.stats.files_failed > 0 {
println!("⚠️ {} files failed to process:", self.stats.files_failed);
for failure in &self.stats.failed_files {
println!(" {}", failure);
}
}
println!("Index saved to {}", index_path.display());
info!("Indexing completed - Files discovered: {}, processed: {}, failed: {}, chunks: {}, embeddings: {}, duration: {:?}",
self.stats.files_discovered,
self.stats.files_processed,
self.stats.files_failed,
self.stats.chunks_created,
self.stats.embeddings_generated,
duration
);
Ok(())
}
pub fn stats(&self) -> &IndexingStats {
&self.stats
}
pub fn elapsed(&self) -> Duration {
self.start_time.elapsed()
}
pub fn has_failures(&self) -> bool {
self.stats.files_failed > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_indexing_progress_creation() {
let progress = IndexingProgress::new(true);
assert_eq!(progress.stats.files_discovered, 0);
assert_eq!(progress.stats.files_processed, 0);
assert_eq!(progress.stats.chunks_created, 0);
assert!(progress.enabled);
}
#[test]
fn test_stats_calculation() {
let stats = IndexingStats {
files_discovered: 10,
files_processed: 8,
files_failed: 2,
..Default::default()
};
assert_eq!(stats.success_rate(), 80.0);
assert_eq!(stats.total_attempted_files(), 10);
}
#[test]
fn test_record_operations() {
let mut progress = IndexingProgress::new(false);
progress.record_file_success(5, 5);
assert_eq!(progress.stats.files_processed, 1);
assert_eq!(progress.stats.chunks_created, 5);
assert_eq!(progress.stats.embeddings_generated, 5);
let error = anyhow::anyhow!("Test error");
progress.record_file_failure(&PathBuf::from("test.txt"), &error);
assert_eq!(progress.stats.files_failed, 1);
assert_eq!(progress.stats.failed_files.len(), 1);
assert!(progress.has_failures());
}
#[test]
fn test_disabled_progress() {
let mut progress = IndexingProgress::new(false);
assert!(progress.start_discovery().is_ok());
assert!(progress.finish_discovery(5).is_ok());
assert!(progress.start_processing(5).is_ok());
assert!(progress
.update_processing(&PathBuf::from("test.txt"))
.is_ok());
assert!(progress
.finish_processing(&PathBuf::from(".turboprop"))
.is_ok());
}
}