--- .\Cargo.toml ---
[package]
name = "file2txt"
version = "0.1.2"
edition = "2024"
description = "将目录下所有文本文件内容聚合到一个文件"
license = "MIT"
repository = "https://github.com/klp-xkjt/file2txt"
readme = "readme.md"
authors = ["klp-xkjt <xkjt-tnt@outlook.com>"]

[[bin]]
name = "file2txt"
path = "src/main.rs"

[dependencies]
walkdir = "2"
clap = { version = "4.6", features = ["derive"] }

--- .\src\filter_config.rs ---
// 默认的常用扩展名配置
pub const DEFAULT_EXTENSIONS: &[&str] = &[
    "rs", "toml", "md", "txt", "json", "yaml", "yml",
    "js", "ts", "jsx", "tsx", "html", "css", "scss", "less",
    "xml", "svg", "graphql", "gql",
    "sh", "bash", "zsh", "fish",
    "py", "pyc",
    "go", "mod", "sum",
    "c", "cpp", "h", "hpp", "cc", "cxx",
    "java", "kt", "kts", "scala", "clj", "cljs",
    "rb", "erb", "rake",
    "php", "phtml",
    "swift", "m", "mm",
    "rs", "rlib",  // Rust
    "cs", "fs", "fsx", // C#, F#
    "vue", "svelte",
    "ini", "cfg", "conf", "config",
    "log", "gitignore", "gitattributes", "dockerignore",
    "env", "example", "lock", "license", "readme", "authors",
    "sql", "psql",
    "tex", "latex",
    "org", "wiki",
    "adoc", "asciidoc"
];

// 过滤的类型
pub enum FilterDecision {
    Keep, // 保留
    ExcludeDir, // 以目录排除
    ExcludeExt, // 以后缀名排除
    ExcludeSize, // 以文件大小排除
    ExcludeNotFile, // 以是否为二进制或者非文件排除
}

pub struct FilterConfig {
    pub extensions: Vec<String>,
    pub exclude_dirs: Vec<String>,
    pub max_size: u64
}
impl FilterConfig {
    // 创建默认配置
    pub fn default() -> Self {
        Self {
            extensions: DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),
            exclude_dirs: vec![".git".to_string(), "target".to_string(), "node_modules".to_string()],
            max_size: 1024 * 1024, // 1MB
        }
    }
    // 判断是否符合要求
    pub fn decide(&self, entry: &walkdir::DirEntry) -> FilterDecision {
        let path = entry.path();

        // 判断是否为路径并排除
        if !entry.file_type().is_file() {
            return FilterDecision::ExcludeNotFile;
        }

        // 判断是否为被忽略的目录并排除其中文件
        for exclude in &self.exclude_dirs {
            if path.components().any(|e| &e.as_os_str().to_string_lossy().to_string() == exclude) {
                return FilterDecision::ExcludeDir;
            }
        }
        // 判断文件扩展名
        if !self.extensions.is_empty() {
            if let Some(ext) = path.extension().and_then(|x| x.to_str()) {
                if !self.extensions.contains(&ext.to_string()) {
                    return FilterDecision::ExcludeExt;
                }
            } else {
                return FilterDecision::ExcludeExt;
            }
        }
        // 判断文件大小
        if self.max_size > 0 {
            if let Ok(meta) = entry.metadata() {
                if meta.len() > self.max_size {
                    return FilterDecision::ExcludeSize;
                }
            }
        }
        FilterDecision::Keep
    }
}

--- .\src\lib.rs ---
mod filter_config;
pub use filter_config::*;

use std::fs;
use std::io::Write;
use std::error::Error;
use std::path::Path;
use walkdir::WalkDir;

pub struct CollectStats {
    pub all_processed: usize, // 总扫描的数量
    pub included: usize, // 最终包含的文件数量
    pub excluded_by_ext: usize, // 以后缀排除的文件数
    pub excluded_by_dir: usize, // 以目录排除的文件数
    pub excluded_by_size: usize, // 以文件大小排除的文件数
    pub exclude_by_not_file: usize // 排除的二进制文件或其他不是文件的数量
}
impl Default for CollectStats {
    fn default() -> Self {
        Self { all_processed: 0, included: 0, excluded_by_ext: 0, excluded_by_dir: 0, excluded_by_size: 0, exclude_by_not_file: 0}
    }
}

#[derive(Debug)]
pub struct File {
    pub name: String,
    pub content: String,
    pub dir: String
}
impl File {
    pub fn new(name: String, content: String, dir: String) -> Self {
        Self { name, content, dir }
    }
    // 通过文件路径来创建 File
    pub fn from_path(path: &Path) -> Result<Self, Box<dyn Error>> {
        let name = path.to_string_lossy().to_string();
        // let content = fs::read_to_string(path)?;
        let content = match fs::read_to_string(path) {
            Ok(c) => c,
            Err(e) => return Err(Box::new(e))
        };
        let dir = path.parent().unwrap_or_else(|| path).to_string_lossy().to_string();
        Ok(Self {
            name,
            content,
            dir
        })
    }
}

// 用 Walkdir 循环递归目录，返回 Result
pub fn collect_files(filter: &FilterConfig) -> Result<(Vec<File>, CollectStats), Box<dyn Error>> {
    let mut files = Vec::new();
    let mut stats = CollectStats::default();
    
    for entry in WalkDir::new(".") {        
        let entry = entry?;
        stats.all_processed += 1;

        match filter.decide(&entry) {
            FilterDecision::Keep => {
                if let Ok(file) = File::from_path(entry.path()) {
                    files.push(file);
                    stats.included += 1;
                }
            },
            FilterDecision::ExcludeDir => {
                stats.excluded_by_dir += 1;
            },
            FilterDecision::ExcludeExt => {
                stats.excluded_by_ext += 1;
            },
            FilterDecision::ExcludeSize => {
                stats.excluded_by_size += 1;
            },
            FilterDecision::ExcludeNotFile => {
                stats.exclude_by_not_file += 1;
            }
        }
    }
    
    Ok((files, stats))
}

// 将 File 相关信息写入输出文件
pub fn write_bundle(files: &[File], output_path: &str) -> Result<(), Box<dyn Error>> {
    let mut output = fs::File::create(output_path)?;
    
    for file in files {
        writeln!(output, "--- {} ---", file.name)?;
        writeln!(output, "{}", file.content)?;
        writeln!(output)?;
    }
    
    Ok(())
}

--- .\src\main.rs ---
use file2txt::{FilterConfig, collect_files, write_bundle, DEFAULT_EXTENSIONS};
use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// 指定输出文件 默认是 output.txt
    #[arg(short, long, default_value = "output.txt")]
    output: String,

    /// 指定文件最大大小（KB），默认 1024
    #[arg(short, long, default_value = "1024")]
    max_size: u64,

    /// 指定保留哪些后缀名文件（逗号分隔，例如：-e rs,toml,md）
    /// 不指定时使用内置的常用扩展名列表
    #[arg(short='e', long, value_delimiter = ',')]
    extensions: Option<Vec<String>>,

    /// 指定排除哪些目录（命令运行的同级目录）(逗号分隔，例如 --exclude_dirs .git,node_modules,target)
    /// 不指定时默认排除 .git node_modules target
    #[arg(short='d', long, value_delimiter = ',')]
    exclude_dirs: Option<Vec<String>>
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    let extensions = match cli.extensions {
        Some(exts) => exts,  // 用户指定了，用用户的
        None => DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),  // 用户没指定，用默认的
    };
    let exclude_dirs = match cli.exclude_dirs {
        Some(exc) => exc,
        None =>  vec![".git".to_string(), "target".to_string(), "node_modules".to_string()]
    };

    let filter = FilterConfig {
        extensions,
        exclude_dirs,
        max_size: cli.max_size * 1024
    };
    let (files, stats) = collect_files(&filter)?;
    println!("📊 统计信息:");
    println!("   扫描总数: {}", stats.all_processed);
    println!("   包含文件: {}", stats.included);
    println!("   排除总数: {}", stats.all_processed - stats.included);
    println!("   ├─ 目录排除: {}", stats.excluded_by_dir);
    println!("   ├─ 扩展名排除: {}", stats.excluded_by_ext);
    println!("   ├─ 大小排除 (>{}KB): {}", cli.max_size, stats.excluded_by_size);
    println!("   └─ 二进制或非文件: {}",stats.exclude_by_not_file);
    
    let output = cli.output;
    write_bundle(&files, &output)?; 
    Ok(())
}

