use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use colored::*;
use ignore::Walk;
use indicatif::{ProgressBar, ProgressStyle};
use glob::Pattern;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(default_value = ".")]
path: String,
#[arg(short, long, default_value = "concatenated")]
output: String,
#[arg(short, long, default_value = "ts,tsx")]
extensions: String,
#[arg(long)]
estimate_tokens: bool,
#[arg(long)]
no_open: bool,
#[arg(short, long)]
exclude: Vec<String>,
#[arg(long)]
include_node_modules: bool,
#[arg(long)]
no_default_ignores: bool,
#[arg(long)]
strip_spaces: bool,
#[arg(long)]
include_no_ext: bool,
}
#[derive(Debug)]
struct SourceFile {
path: PathBuf,
content: String,
extension: Option<String>,
}
fn main() -> Result<()> {
let args = Args::parse();
let extensions: Vec<String> = args.extensions
.split(',')
.map(|s| s.trim().to_lowercase())
.collect();
println!("{}", format!("🔍 Searching for files with extensions: {}",
extensions.join(", ")).blue());
let output_dir = PathBuf::from("tmp");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}.txt", args.output));
let md_output_path = output_dir.join(format!("{}.md", args.output));
let files = collect_files(&args.path, &extensions, &args.exclude, args.include_no_ext, &args)?;
if files.is_empty() {
anyhow::bail!("No matching files found in the specified path");
}
println!("{}", format!("Found {} files", files.len()).green());
let pb = ProgressBar::new((files.len() * 2) as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
.progress_chars("#>-"),
);
let mut total_chars = 0;
let mut output_file = File::create(&output_path)?;
let mut md_output = File::create(&md_output_path)?;
writeln!(md_output, "# Combined Files Structure")?;
writeln!(md_output, "\nIncluded extensions: {}\n", extensions.join(", "))?;
for file in &files {
let separator = "\n\n// ===========================================\n";
let header = format!("// File: {} ({})\n// ===========================================\n\n",
file.path.display(),
file.extension.as_deref().unwrap_or("no extension"));
let content = if args.strip_spaces {
file.content
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("#") {
trimmed.to_string()
} else {
let indent_level = line.chars().take_while(|c| c.is_whitespace()).count();
let indent = " ".repeat(indent_level);
format!("{}{}", indent, trimmed.split_whitespace().collect::<Vec<_>>().join(" "))
}
})
.collect::<Vec<_>>()
.join("\n")
} else {
file.content.clone()
};
write!(output_file, "{}{}{}", separator, header, content)?;
total_chars += content.len();
writeln!(md_output, "## {} ({})",
file.path.display(),
file.extension.as_deref().unwrap_or("no extension"))?;
pb.inc(2);
}
pb.finish_with_message("Done!");
if args.estimate_tokens {
let estimated_tokens = total_chars / 4;
println!("{}", format!("\nEstimated tokens: {}", estimated_tokens).magenta());
}
println!("{}", format!("\n✅ Successfully processed files").green());
println!("{}", format!("📁 Output saved to: {}", output_path.display()).blue());
println!("{}", format!("📝 Markdown saved to: {}", md_output_path.display()).blue());
if !args.no_open {
if let Err(e) = open::that(output_dir) {
eprintln!("Failed to open output directory: {}", e);
}
}
Ok(())
}
fn collect_files(
root: &str,
extensions: &[String],
exclude_patterns: &[String],
include_no_ext: bool,
args: &Args
) -> Result<Vec<SourceFile>> {
let mut files = Vec::new();
let walker = Walk::new(root);
for entry in walker.filter_map(Result::ok) {
let path = entry.path();
if should_exclude(path, exclude_patterns, args) {
continue;
}
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if extensions.contains(&ext) {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
files.push(SourceFile {
path: path.to_path_buf(),
content,
extension: Some(ext),
});
}
} else if include_no_ext {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
files.push(SourceFile {
path: path.to_path_buf(),
content,
extension: None,
});
}
}
Ok(files)
}
fn should_exclude(path: &Path, exclude_patterns: &[String], args: &Args) -> bool {
if !args.no_default_ignores {
let default_ignores = [
"node_modules",
".git",
"target",
"dist",
"build",
".cache",
".temp",
"tmp",
];
if !args.include_node_modules && path.to_string_lossy().contains("node_modules") {
return true;
}
for pattern in default_ignores.iter() {
if path.to_string_lossy().contains(pattern) {
return true;
}
}
}
exclude_patterns.iter().any(|pattern| {
let matcher = Pattern::new(pattern).unwrap_or_else(|_| {
eprintln!("Warning: Invalid exclude pattern: {}", pattern);
Pattern::new("").unwrap()
});
matcher.matches_path(path)
})
}