dumpfs 0.1.0

A tool for dumping codebase information for LLMs efficiently and effectively
Documentation
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},
    },
};

/// Holds statistics collected during the scan, using atomics for thread safety.
#[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 {
    /// Generates a formatted report string based on the scan statistics and options.
    pub fn format_report(&self, opts: FsScanReportOpts) -> Result<String, fmt::Error> {
        ScanReportGenerator::new(self, opts).generate()
    }
}

/// Internal struct responsible for generating the report content.
struct ScanReportGenerator<'a> {
    stats: &'a FsScanStats,
    opts: FsScanReportOpts<'a>,
}

/// Configuration options for generating a scan report.
#[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,
}

/// Represents different formats for the report output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsScanReportFormat {
    Console,
    // Markdown, // Future format
    // Json,     // Future format
}

/// Represents a row in the top files table.
#[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,
}

/// Represents a row in the summary table.
#[derive(Tabled)]
struct SummaryRow {
    #[tabled(rename = "Metric")]
    metric: String,
    #[tabled(rename = "Value")]
    value: String,
}

impl<'a> ScanReportGenerator<'a> {
    /// Creates a new report generator.
    fn new(stats: &'a FsScanStats, opts: FsScanReportOpts<'a>) -> Self {
        Self { stats, opts }
    }

    /// Generates the report string based on the configured format.
    fn generate(&self) -> Result<String, fmt::Error> {
        match self.opts.format {
            FsScanReportFormat::Console => self.generate_console_report(),
        }
    }

    /// Generates the report formatted for console output.
    fn generate_console_report(&self) -> Result<String, fmt::Error> {
        use fmt::Write;
        let mut output = String::new();

        // --- Top Files Table ---
        if self.stats.text_files_processed() > 0 {
            let (files_header, files_table_str) = self.create_files_table();
            writeln!(&mut output, "{}", files_header)?; // Print header first
            writeln!(&mut output)?; // Blank line after header
            writeln!(&mut output, "{}", files_table_str)?; // Then print table
            writeln!(&mut output)?; // Add a blank line after table
        } else {
            writeln!(&mut output, "No text files processed.")?;
            writeln!(&mut output)?;
        }

        // --- Summary Table ---
        let summary_table_str = self.create_summary_table();
        writeln!(&mut output, "✅  EXTRACTION COMPLETE")?; // Print header first
        writeln!(&mut output)?; // Blank line after header
        writeln!(&mut output, "{}", summary_table_str)?; // Then print table

        Ok(output)
    }

    /// Creates the summary table section.
    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),
            },
        ];

        // --- Token Information (Always Add) ---
        let total_tokens_counted = self.stats.total_tokens();
        let total_estimated_tokens = self.stats.total_chars() / 4; // Simple estimation

        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,
        });

        // --- Add Cache Stats (Only if model was used and tokens counted) ---
        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()
    }

    /// Creates the header and table string for the top files section. (header_string, table_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 and a message if no files
            return (header_text, "No text files with content found.".to_string());
        }

        // Sort by character count descending
        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 // Show all if top_n is negative
        } 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();

        // Note: header_text is now generated earlier
        // match self.opts.model_name {
        //             Some(_) => "Tokens"
        //             None => "Est. Tokens"
        //         }
        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)
    }
}

/// Wrapper function for `tabled` to format usize using `format_number_human`.
fn format_usize_human(num: &usize) -> String {
    format_number_human(*num as f64)
}

/// Formats a path relative to the scan root for display.
fn format_path(path: &Path, root_path: &Path) -> String {
    path.strip_prefix(root_path)
        .unwrap_or(path) // Fallback to original path if not relative
        .display()
        .to_string()
}

impl FsScanStats {
    // --- Public Accessors (Load values with Ordering::Relaxed) ---
    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)
    }

    // --- Calculated Totals ---
    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()
    }
}