385 lines
No EOL
13 KiB
Rust
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(())
|
|
} |