use std::collections::HashSet; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; /// Directories to always skip, regardless of .gitignore const IGNORED_DIRS: &[&str] = &[ "target", "node_modules", // "resources", ".git", "dist", "build", ".next", ".nuxt", ".cache", ".turbo", "out", "pkg", "vendor", "coverage", "__pycache__", ]; /// Files to always skip const IGNORED_FILES: &[&str] = &[ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "*.txt", // All cad files "Cargo.lock", "map.txt", "security_tests.rs", "security_suite.rs", ]; /// Specific files/directories to always include (with specific extensions) const ADD_FILES: &[&str] = &[ "*.java", // All Java files "*.cad", // All cad files "*.splash", // All cad files "*.py", // All cad files "*.obj", // All obj files "*.kt", // Kotlin files "*.gradle", // Gradle build files "*.xml", // XML configuration files "*.properties", // Properties files "pom.xml", // Maven configuration "build.gradle", // Gradle configuration "settings.gradle", // Gradle settings ]; fn is_ignored_dir(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| IGNORED_DIRS.contains(&name)) .unwrap_or(false) } fn is_ignored_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| IGNORED_FILES.contains(&name)) .unwrap_or(false) } fn is_hidden(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| name.starts_with('.')) .unwrap_or(false) } /// Matches this tool's own output files, e.g. "pdf_concatenated_code.txt" or the /// older unprefixed "concatenated_code.txt". Any such file found while scanning /// (left over from an earlier run, possibly with a different folder ordering) is /// skipped so its contents never get sucked back in as if it were source code. fn is_previous_output_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| name == "concatenated_code.txt" || name.ends_with("_concatenated_code.txt")) .unwrap_or(false) } fn is_git_ignored(root: &Path, path: &Path) -> bool { let relative_path = path.strip_prefix(root).unwrap_or(path); let relative_path_str = match relative_path.to_str() { Some(s) => s, None => return false, }; let output = Command::new("git") .args(["check-ignore", "-q", relative_path_str]) .current_dir(root) .output(); match output { Ok(output) => output.status.success(), Err(_) => false, // git not available — don't skip } } fn matches_add_files_pattern(path: &Path) -> bool { let file_name = match path.file_name().and_then(|n| n.to_str()) { Some(name) => name, None => return false, }; let path_str = path.to_str().unwrap_or(""); for pattern in ADD_FILES { if pattern.starts_with("*.") { // Handle wildcard extension patterns like "*.java" let ext = &pattern[2..]; // Remove the "*." if path.extension() .and_then(|e| e.to_str()) .map(|e| e == ext) .unwrap_or(false) { return true; } } else { // Handle exact file name matches if file_name == *pattern || path_str.ends_with(pattern) { return true; } } } false } fn should_skip(root: &Path, path: &Path) -> bool { // Check if file should be included based on ADD_FILES patterns if path.is_file() && matches_add_files_pattern(path) { return false; // Don't skip files that match ADD_FILES patterns } if is_hidden(path) { println!(" skip (hidden): {}", path.display()); return true; } if path.is_dir() && is_ignored_dir(path) { println!(" skip (ignored dir): {}", path.display()); return true; } if path.is_file() && is_ignored_file(path) { println!(" skip (ignored file): {}", path.display()); return true; } if path.is_file() && is_previous_output_file(path) { println!(" skip (old output): {}", path.display()); return true; } if is_git_ignored(root, path) { println!(" skip (gitignored): {}", path.display()); return true; } false } const VALID_EXTENSIONS: &[&str] = &[ "rs", "toml", "mjs", "md", "txt", "js", "ts", "tsx", "html", "css", "json", "yaml", "yml", "env", "sh", "sql", "graphql", "proto", "dockerfile", "java", "kt", "gradle", "xml", "properties", ]; fn has_valid_extension(path: &Path) -> bool { // Also allow files with no extension but known names let known_extensionless = &["Makefile", "Dockerfile", "Justfile", ".env", ".env.example", "pom.xml", "build.gradle", "settings.gradle"]; if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if known_extensionless.contains(&name) { return true; } } path.extension() .and_then(|ext| ext.to_str()) .map(|ext| VALID_EXTENSIONS.contains(&ext.to_lowercase().as_str())) .unwrap_or(false) } /// Short label for a root folder, used both to build the output file name and to /// prefix each "// File:" header so it's obvious which folder a block came from /// once multiple folders are combined into one output. fn folder_label(path: &Path) -> String { path.file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| "root".to_string()) } fn copy_files_to_single_file( source_dir: &Path, output_file: &mut File, root: &Path, output_file_abs_path: &Path, seen_files: &mut HashSet, ) -> Result<(), io::Error> { // Read and sort entries for deterministic output let mut entries: Vec<_> = fs::read_dir(source_dir)? .filter_map(|e| e.ok()) .collect(); entries.sort_by_key(|e| e.path()); for entry in entries { let path = entry.path(); if should_skip(root, &path) { continue; } if path.is_dir() { copy_files_to_single_file(&path, output_file, root, output_file_abs_path, seen_files)?; } else if path.is_file() { let file_abs_path = fs::canonicalize(&path)?; // Skip the output file itself if file_abs_path == output_file_abs_path { println!(" skip (output file): {}", path.display()); continue; } if !has_valid_extension(&path) && !matches_add_files_pattern(&path) { println!(" skip (extension): {}", path.display()); continue; } // If two provided root folders overlap (e.g. one nested inside another), // make sure the same file never gets written into the output twice. if !seen_files.insert(file_abs_path) { println!(" skip (duplicate): {}", path.display()); continue; } let relative_path = path.strip_prefix(root).unwrap_or(&path); let label = folder_label(root); writeln!(output_file, "// File: {}/{}\n", label, relative_path.display())?; let mut file = File::open(&path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; writeln!(output_file, "{}\n", contents)?; println!(" ✓ included: {}/{}", label, relative_path.display()); } } Ok(()) } fn copy_single_file( path: &Path, output_file: &mut File, output_file_abs_path: &Path, seen_files: &mut HashSet, ) -> io::Result<()> { if should_skip(path.parent().unwrap_or(Path::new(".")), path) { return Ok(()); } let file_abs_path = fs::canonicalize(path)?; if file_abs_path == output_file_abs_path { println!(" skip (output file): {}", path.display()); return Ok(()); } if !has_valid_extension(path) && !matches_add_files_pattern(path) { println!(" skip (extension): {}", path.display()); return Ok(()); } if !seen_files.insert(file_abs_path) { println!(" skip (duplicate): {}", path.display()); return Ok(()); } writeln!(output_file, "// File: {}\n", path.display())?; let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; writeln!(output_file, "{}\n", contents)?; println!(" ✓ included: {}", path.display()); Ok(()) } fn main() -> Result<(), io::Error> { // Add as many folders as you like here — they all get combined into one output file. // let project_roots: Vec = vec![ // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/nigig-org/crates/apps/nigig-pay"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/nigig-org/crates/apps/nigig-pay-ui"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/nigig-org/crates/nigig-core"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/robius-sms"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/robius-fingerprinting"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/robius-trigger"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/robius-ussd"), // PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/robius-contacts"), // ]; let project_roots: Vec = vec![ PathBuf::from("/Users/aok/Projects/rustdev/CratesCode/nigig-org/crates/apps/nigig-build/src/construction_frame/pages/workspace/cost_estimator"), ]; if project_roots.is_empty() { eprintln!("No project roots specified — nothing to do."); return Ok(()); } // The output file is named after the first (or only) folder, and is written // inside that folder. Change `output_dir` below if you'd rather it land somewhere else. let first_root = &project_roots[0]; let output_dir = first_root.clone(); let output_file_name = format!("{}_concatenated_code.txt", folder_label(first_root)); let output_file_path = output_dir.join(&output_file_name); // Ensure the output file exists so canonicalize works if !output_file_path.exists() { File::create(&output_file_path)?; } let output_file_abs_path = fs::canonicalize(&output_file_path)?; let mut output_file = File::create(&output_file_path)?; println!("╔══════════════════════════════════════════╗"); println!("║ Code Concatenation Tool ║"); println!("╚══════════════════════════════════════════╝"); println!(); println!("Roots ({}):", project_roots.len()); for r in &project_roots { println!(" - {}", r.display()); } println!("Output: {}", output_file_abs_path.display()); println!(); println!("Ignored dirs: {}", IGNORED_DIRS.join(", ")); println!("Ignored files: {}", IGNORED_FILES.join(", ")); println!("Add files: {}", ADD_FILES.join(", ")); println!("Valid exts: {}", VALID_EXTENSIONS.join(", ")); println!(); // Shared across every root so the same file can't end up written twice even if // two provided folders overlap. let mut seen_files: HashSet = HashSet::new(); for root in &project_roots { println!("── Scanning: {} ──", root.display()); if root.is_dir() { copy_files_to_single_file( root, &mut output_file, root, &output_file_abs_path, &mut seen_files, )?; } else if root.is_file() { copy_single_file( root, &mut output_file, &output_file_abs_path, &mut seen_files, )?; } else { println!(" skip (missing): {}", root.display()); } } // Print summary let output_size = fs::metadata(&output_file_path)?.len(); println!(); println!("════════════════════════════════════════════"); println!( "Done. Output: {} ({} bytes)", output_file_path.display(), output_size ); Ok(()) }