use crate::fs::nodes::FileDetail;
use crate::tk;
use dashmap::DashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tabled::{
Table, Tabled,
settings::{
Alignment, Modify, Style,
object::{Columns, Rows},
},
};
#[derive(Debug, Default)]
pub struct FsScanStats {
pub(super) text_files_processed: AtomicUsize,
pub(super) binary_files_processed: AtomicUsize,
pub(super) symlinks_processed: AtomicUsize,
pub(super) directories_processed: AtomicUsize,
pub(super) total_lines: AtomicUsize,
pub(super) total_chars: AtomicUsize,
pub(super) total_bytes: AtomicU64,
pub(super) files_skipped_large_content: AtomicUsize,
pub(super) files_skipped_read_error: AtomicUsize,
pub(super) entries_skipped_date: AtomicUsize,
pub(super) entries_skipped_permission: AtomicUsize,
pub(super) entries_skipped_error: AtomicUsize,
pub(super) walker_paths_sent: AtomicUsize,
pub(super) file_details: DashMap<PathBuf, FileDetail>,
pub(super) total_tokens: AtomicUsize,
}
impl FsScanStats {
pub fn format_report(&self, opts: FsScanReportOpts) -> Result<String, fmt::Error> {
ScanReportGenerator::new(self, opts).generate()
}
}
struct ScanReportGenerator<'a> {
stats: &'a FsScanStats,
opts: FsScanReportOpts<'a>,
}
#[derive(Debug, Clone)]
pub struct FsScanReportOpts<'a> {
pub duration: &'a Duration,
pub root_path: &'a PathBuf,
pub root_title: String,
pub model_name: Option<String>,
pub top_n: isize,
pub format: FsScanReportFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsScanReportFormat {
Console,
}
#[derive(Tabled)]
struct TopFileRow {
#[tabled(rename = "File Path")]
path: String,
#[tabled(rename = "Lines", display = "format_usize_human")]
lines: usize,
#[tabled(rename = "Tokens", display = "format_usize_human")]
est_tokens: usize,
}
#[derive(Tabled)]
struct SummaryRow {
#[tabled(rename = "Metric")]
metric: String,
#[tabled(rename = "Value")]
value: String,
}
impl<'a> ScanReportGenerator<'a> {
fn new(stats: &'a FsScanStats, opts: FsScanReportOpts<'a>) -> Self {
Self { stats, opts }
}
fn generate(&self) -> Result<String, fmt::Error> {
match self.opts.format {
FsScanReportFormat::Console => self.generate_console_report(),
}
}
fn generate_console_report(&self) -> Result<String, fmt::Error> {
use fmt::Write;
let mut output = String::new();
if self.stats.text_files_processed() > 0 {
let (files_header, files_table_str) = self.create_files_table();
writeln!(&mut output, "{}", files_header)?; writeln!(&mut output)?; writeln!(&mut output, "{}", files_table_str)?; writeln!(&mut output)?; } else {
writeln!(&mut output, "No text files processed.")?;
writeln!(&mut output)?;
}
let summary_table_str = self.create_summary_table();
writeln!(&mut output, "✅ EXTRACTION COMPLETE")?; writeln!(&mut output)?; writeln!(&mut output, "{}", summary_table_str)?;
Ok(output)
}
fn create_summary_table(&self) -> String {
let duration_ms = self.opts.duration.as_millis();
let total_files = self.stats.text_files_processed() + self.stats.binary_files_processed();
let mut summary_data: Vec<SummaryRow> = vec![
SummaryRow {
metric: "Process Time".to_string(),
value: format!("{:.0}ms", duration_ms as f64),
},
SummaryRow {
metric: "Files Processed".to_string(),
value: format_number_human(total_files as f64),
},
SummaryRow {
metric: "Total Lines".to_string(),
value: format_number_human(self.stats.total_lines() as f64),
},
];
let total_tokens_counted = self.stats.total_tokens();
let total_estimated_tokens = self.stats.total_chars() / 4;
let (token_metric_name, token_value_str) = if let Some(model_name) = &self.opts.model_name {
if total_tokens_counted > 0 {
(
format!("LLM Tokens ({})", model_name),
format!(
"{} tokens",
format_number_human(total_tokens_counted as f64)
),
)
} else {
(
format!("LLM Tokens ({})", model_name),
format!(
"{} tokens (estimated)",
format_number_human(total_estimated_tokens as f64)
),
)
}
} else {
(
"LLM Tokens".to_string(),
format!(
"{} tokens (estimated)",
format_number_human(total_estimated_tokens as f64)
),
)
};
summary_data.push(SummaryRow {
metric: token_metric_name,
value: token_value_str,
});
if self.opts.model_name.is_some() && total_tokens_counted > 0 {
let cache_stats = tk::get_global_cache_stats();
summary_data.push(SummaryRow {
metric: "Token Cache Hits".to_string(),
value: format_number_human(cache_stats.hits as f64),
});
summary_data.push(SummaryRow {
metric: "Token Cache Misses".to_string(),
value: format_number_human(cache_stats.misses as f64),
});
}
let mut table = Table::new(summary_data);
table
.with(Style::modern_rounded())
.with(Modify::new(Columns::first()).with(Alignment::left()))
.with(Modify::new(Columns::last()).with(Alignment::right()));
table.to_string()
}
fn create_files_table(&self) -> (String, String) {
let mut details: Vec<_> = self
.stats
.file_details()
.iter()
.filter(|entry| entry.value().chars > 0)
.map(|entry| {
let path = entry.key();
let detail = entry.value();
let est_tokens = if detail.chars > 0 {
detail.chars / 4
} else {
0
};
(
format_path(path, self.opts.root_path),
detail.lines,
detail.chars,
est_tokens,
)
})
.collect();
let header_text = if self.opts.top_n < 0 {
format!(
"📋 ALL FILES BY CHARACTER COUNT ('{}')",
self.opts.root_title
)
} else {
let limit = std::cmp::min(details.len(), self.opts.top_n as usize);
format!(
"📋 TOP {} LARGEST BY CHARACTER COUNT ('{}')",
limit, self.opts.root_title
)
};
if details.is_empty() {
return (header_text, "No text files with content found.".to_string());
}
details.sort_by(|a, b| b.2.cmp(&a.2));
let total_files = details.len();
let limit = if self.opts.top_n < 0 {
total_files } else {
std::cmp::min(total_files, self.opts.top_n as usize)
};
let display_details: Vec<TopFileRow> = details
.into_iter()
.take(limit)
.map(|(path, lines, _, est_tokens)| TopFileRow {
path,
lines,
est_tokens,
})
.collect();
let mut table = Table::new(display_details);
table
.with(Style::modern_rounded())
.with(Modify::new(Rows::first()).with(Alignment::center()))
.with(Modify::new(Rows::new(1..)).with(Alignment::left()));
let table_string = table.to_string();
(header_text, table_string)
}
}
fn format_number_human(n: f64) -> String {
if n >= 1_000_000.0 {
format!("{:.1}M", n / 1_000_000.0)
} else if n >= 1_000.0 {
format!("{:.1}K", n / 1_000.0)
} else if n.fract() == 0.0 {
format!("{:.0}", n)
} else {
format!("{:.1}", n)
}
}
fn format_usize_human(num: &usize) -> String {
format_number_human(*num as f64)
}
fn format_path(path: &Path, root_path: &Path) -> String {
path.strip_prefix(root_path)
.unwrap_or(path) .display()
.to_string()
}
impl FsScanStats {
pub fn text_files_processed(&self) -> usize {
self.text_files_processed.load(Ordering::Relaxed)
}
pub fn binary_files_processed(&self) -> usize {
self.binary_files_processed.load(Ordering::Relaxed)
}
pub fn symlinks_processed(&self) -> usize {
self.symlinks_processed.load(Ordering::Relaxed)
}
pub fn directories_processed(&self) -> usize {
self.directories_processed.load(Ordering::Relaxed)
}
pub fn total_lines(&self) -> usize {
self.total_lines.load(Ordering::Relaxed)
}
pub fn total_chars(&self) -> usize {
self.total_chars.load(Ordering::Relaxed)
}
pub fn total_bytes(&self) -> u64 {
self.total_bytes.load(Ordering::Relaxed)
}
pub fn files_skipped_large_content(&self) -> usize {
self.files_skipped_large_content.load(Ordering::Relaxed)
}
pub fn files_skipped_read_error(&self) -> usize {
self.files_skipped_read_error.load(Ordering::Relaxed)
}
pub fn entries_skipped_date(&self) -> usize {
self.entries_skipped_date.load(Ordering::Relaxed)
}
pub fn entries_skipped_permission(&self) -> usize {
self.entries_skipped_permission.load(Ordering::Relaxed)
}
pub fn entries_skipped_error(&self) -> usize {
self.entries_skipped_error.load(Ordering::Relaxed)
}
pub fn walker_paths_sent(&self) -> usize {
self.walker_paths_sent.load(Ordering::Relaxed)
}
pub fn file_details(&self) -> &DashMap<PathBuf, FileDetail> {
&self.file_details
}
pub fn total_tokens(&self) -> usize {
self.total_tokens.load(Ordering::Relaxed)
}
pub fn total_entries_processed(&self) -> usize {
self.text_files_processed()
+ self.binary_files_processed()
+ self.symlinks_processed()
+ self.directories_processed()
}
pub fn total_entries_skipped(&self) -> usize {
self.entries_skipped_date()
+ self.entries_skipped_permission()
+ self.entries_skipped_error()
+ self.files_skipped_large_content()
+ self.files_skipped_read_error()
}
}