nigig-org/crates/copy_code.rs
2026-07-26 19:38:26 +03:00

385 lines
No EOL
13 KiB
Rust

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<PathBuf>,
) -> 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<PathBuf>,
) -> 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<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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(())
}