1pub mod config;
36pub mod exclusions;
37
38use anyhow::{Context, Result};
39use clap::Parser;
40use console::{style, Emoji};
41use exclusions::ExclusionManager;
42use indicatif::{ProgressBar, ProgressStyle};
43use memmap2::MmapOptions;
44use rayon::prelude::*;
45use std::collections::HashSet;
46use std::ffi::OsStr;
47use std::fs::{File, OpenOptions};
48use std::io::Write;
49use std::path::{Path, PathBuf};
50use std::sync::atomic::{AtomicUsize, Ordering};
51use walkdir::WalkDir;
52
53static FOLDER: Emoji<'_, '_> = Emoji("📁", "DIR");
54static FILE: Emoji<'_, '_> = Emoji("📄", "FILE");
55static SKIP: Emoji<'_, '_> = Emoji("⏭️", "SKIP");
56static ROCKET: Emoji<'_, '_> = Emoji("🚀", "=>");
57const PROGRESS_STYLE: &str =
58 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})";
59
60#[derive(Parser, Debug)]
65#[command(name = "flatten-rust")]
66#[command(about = "High-performance codebase flattening tool with intelligent exclusions")]
67#[command(version)]
68#[command(after_help = r##"
69УПРАВЛЕНИЕ ИСКЛЮЧЕНИЯМИ:
70 Инструмент использует шаблоны в формате gitignore из API toptal.com для умных исключений.
71 Шаблоны кэшируются в ~/.flatten/ и автоматически обновляются каждые 24 часа.
72
73 Доступные команды для управления исключениями:
74 -l, --list-templates Показать список всех доступных шаблонов
75 -e, --enable-template <TEMPLATE> Включить определенный шаблон
76 -D, --disable-template <TEMPLATE> Отключить определенный шаблон
77 -u, --force-update Принудительно обновить шаблоны из API
78
79 --show-enabled Показать текущие включенные шаблоны
80
81ПРИМЕРЫ:
82 # Базовое использование с авто-определением
83 flatten-rust -f ./project -a
84
85 # Ручной выбор шаблонов
86 flatten-rust -f ./project -e rust -e node
87
88 # Опции производительности
89 flatten-rust -f ./project -t 8 -m 50MB
90
91 # Управление шаблонами
92 flatten-rust -l
93 flatten-rust -u
94"##)]
95pub struct Args {
96 #[arg(long = "folders", short = 'f', num_args = 1..)]
98 pub folders: Vec<PathBuf>,
99
100 #[arg(long = "skip-folders", short = 's', num_args = 0.., default_values = [
102 ".git", "node_modules", "target", "dist", "build"
103 ])]
104 pub skip_folders: Vec<String>,
105
106 #[arg(long = "output", short = 'o', default_value = "codebase.md")]
108 pub output: PathBuf,
109
110 #[arg(long = "show-skipped", short = 'k')]
112 pub show_skipped: bool,
113
114 #[arg(long = "threads", short = 't', default_value = "0")]
116 pub threads: usize,
117
118 #[arg(long = "max-file-size", short = 'm', default_value = "104857600")]
120 pub max_file_size: u64,
121
122 #[arg(long = "skip-extensions", short = 'x', num_args = 0.., default_values = [
124 "exe", "dll", "so", "dylib", "bin", "jar", "apk", "ipa", "msi", "class", "pyc"
125 ])]
126 pub skip_extensions: Vec<String>,
127
128 #[arg(long = "auto-detect", short = 'a')]
130 pub auto_detect: bool,
131
132 #[arg(long = "include-hidden")]
134 pub include_hidden: bool,
135
136 #[arg(long = "max-depth", default_value = "0")]
138 pub max_depth: usize,
139
140 #[arg(long = "stats", short = 'S')]
142 pub show_stats: bool,
143
144 #[arg(long = "dry-run", short = 'd')]
146 pub dry_run: bool,
147
148 #[arg(long = "list-templates", short = 'l')]
150 pub list_templates: bool,
151
152 #[arg(long = "enable-template", short = 'e', num_args = 1..)]
154 pub enable_templates: Vec<String>,
155
156 #[arg(long = "disable-template", short = 'D', num_args = 1..)]
158 pub disable_templates: Vec<String>,
159
160 #[arg(long = "force-update", short = 'u')]
162 pub force_update: bool,
163
164 #[arg(long = "show-enabled")]
166 pub show_enabled: bool,
167}
168
169#[derive(Debug)]
174pub struct FlattenConfig {
175 exclusion_manager: ExclusionManager,
177 skip_folders: HashSet<String>,
179 skip_extensions: HashSet<String>,
181 show_skipped: bool,
183 max_file_size: u64,
185 include_hidden: bool,
187 max_depth: usize,
189 show_stats: bool,
191 dry_run: bool,
193}
194
195impl FlattenConfig {
196 pub async fn new(args: &Args) -> Result<Self> {
201 let mut exclusion_manager = ExclusionManager::new().await?;
202
203 if args.force_update {
204 exclusion_manager.force_update_templates().await?;
205 }
206
207 if args.list_templates {
208 Self::handle_list_templates(&exclusion_manager).await?;
209 std::process::exit(0);
210 }
211
212 if args.show_enabled {
213 Self::handle_show_enabled(&exclusion_manager);
214 std::process::exit(0);
215 }
216
217 for template in &args.enable_templates {
218 exclusion_manager.enable_template(template.clone());
219 }
220
221 for template in &args.disable_templates {
222 exclusion_manager.disable_template(template);
223 }
224
225 if args.auto_detect && !args.folders.is_empty() {
226 for folder in &args.folders {
227 if folder.exists() {
228 exclusion_manager
229 .enable_templates_for_project(folder)
230 .await?;
231 }
232 }
233 }
234
235 let mut config = Self {
236 skip_folders: args.skip_folders.iter().cloned().collect(),
237 skip_extensions: args.skip_extensions.iter().cloned().collect(),
238 show_skipped: args.show_skipped,
239 max_file_size: args.max_file_size,
240 include_hidden: args.include_hidden,
241 max_depth: args.max_depth,
242 show_stats: args.show_stats,
243 dry_run: args.dry_run,
244 exclusion_manager,
245 };
246
247 let folder_patterns = config.exclusion_manager.get_folder_patterns().await;
248 let extension_patterns = config.exclusion_manager.get_extension_patterns().await;
249
250 config.skip_folders.extend(folder_patterns);
251 config.skip_extensions.extend(extension_patterns);
252
253 Ok(config)
254 }
255
256 async fn handle_list_templates(exclusion_manager: &ExclusionManager) -> Result<()> {
258 let templates = exclusion_manager.get_available_templates().await;
259 println!("Available exclusion templates ({} total):", templates.len());
260 println!();
261 let mut sorted_templates = templates;
262 sorted_templates.sort();
263 for chunk in sorted_templates.chunks(5) {
264 println!(" {}", chunk.join(", "));
265 }
266 Ok(())
267 }
268
269 fn handle_show_enabled(exclusion_manager: &ExclusionManager) {
271 let enabled = exclusion_manager.get_enabled_templates();
272 if enabled.is_empty() {
273 println!("No templates currently enabled.");
274 } else {
275 println!("Enabled templates ({}):", enabled.len());
276 for template in enabled {
277 println!(" - {}", template);
278 }
279 }
280 }
281
282 fn should_skip_path(&self, path: &Path) -> bool {
284 if let Some(name) = path.file_name()
285 && let Some(name_str) = name.to_str() {
286 if !self.include_hidden && name_str.starts_with('.') {
287 return true;
288 }
289 return self.skip_folders.contains(name_str);
290 }
291 false
292 }
293
294 fn should_skip_file(&self, path: &Path) -> bool {
296 if let Some(extension) = path.extension()
297 && let Some(ext_str) = extension.to_str() {
298 return self.skip_extensions.contains(ext_str);
299 }
300 false
301 }
302}
303
304pub async fn run(args: &Args) -> Result<()> {
313 if (args.list_templates
314 || args.show_enabled
315 || args.force_update
316 || !args.enable_templates.is_empty()
317 || !args.disable_templates.is_empty())
318 && args.folders.is_empty()
319 {
320 let _ = FlattenConfig::new(args).await?;
321 return Ok(());
322 }
323
324 if args.folders.is_empty() {
325 return Err(anyhow::anyhow!("Error: --folders argument is required. Use --help for more information."));
326 }
327
328 if args.threads > 0 {
329 rayon::ThreadPoolBuilder::new()
330 .num_threads(args.threads)
331 .build_global()
332 .context("Failed to configure thread pool")?;
333 }
334
335 let config = FlattenConfig::new(args).await?;
336
337 println!("{} Starting flatten process...", ROCKET);
338 println!("Processing {} folders", args.folders.len());
339 if config.dry_run {
340 println!("🔍 DRY RUN MODE - No output file will be created");
341 } else {
342 println!("Output file: {}", args.output.display());
343 }
344 println!();
345
346 let mut output_file = if !config.dry_run {
347 Some(
348 OpenOptions::new()
349 .create(true)
350 .write(true)
351 .truncate(true)
352 .open(&args.output)
353 .with_context(|| {
354 format!("Failed to create output file: {}", args.output.display())
355 })?,
356 )
357 } else {
358 None
359 };
360
361 let total_files = AtomicUsize::new(0);
362 let total_bytes_processed = AtomicUsize::new(0);
363 let mut any_folder_found = false;
364
365 for base_folder in &args.folders {
366 if !base_folder.exists() {
367 eprintln!(
368 "Warning: Folder {} does not exist, skipping",
369 base_folder.display()
370 );
371 continue;
372 }
373 any_folder_found = true;
374
375 println!("Processing folder: {}", base_folder.display());
376
377 if let Some(ref mut output) = output_file {
378 print_folder_structure(base_folder, output, &config)?;
379 } else {
380 println!("📁 Folder structure for {}", base_folder.display());
381 let mut console_output = Vec::new();
382 print_folder_structure(base_folder, &mut console_output, &config)?;
383 println!("{}", String::from_utf8_lossy(&console_output));
384 }
385
386 let files = collect_files(base_folder, &config)?;
387 let file_count = files.len();
388 total_files.fetch_add(file_count, Ordering::Relaxed);
389
390 if file_count == 0 {
391 println!("No files found in {}", base_folder.display());
392 continue;
393 }
394
395 let pb = ProgressBar::new(file_count as u64);
396 pb.set_style(
397 ProgressStyle::default_bar()
398 .template(PROGRESS_STYLE)
399 .context("Invalid progress bar template")?
400 .progress_chars("#>-"),
401 );
402
403 if let Some(ref mut output) = output_file {
404 writeln!(
405 output,
406 "### DIRECTORY {} FLATTENED CONTENT ###",
407 base_folder.display()
408 )?;
409 } else {
410 println!("📄 Files to process from {}:", base_folder.display());
411 }
412
413 let results = process_files_parallel(files, &config, Some(pb.clone()));
414
415 for (file_path, content_result) in results {
416 if let Some(ref mut output) = output_file {
417 writeln!(output, "### {} BEGIN ###", file_path.display())?;
418 match content_result {
419 Ok((content, bytes_processed)) => {
420 output.write_all(content.as_bytes())?;
421 total_bytes_processed
422 .fetch_add(bytes_processed as usize, Ordering::Relaxed);
423 }
424 Err(e) => {
425 writeln!(output, "[Error reading file: {}]", e)?;
426 }
427 }
428 writeln!(output, "\n### {} END ###\n", file_path.display())?;
429 } else {
430 match content_result {
431 Ok((_, bytes_processed)) => {
432 println!(" ✅ {} ({} bytes)", file_path.display(), bytes_processed);
433 total_bytes_processed
434 .fetch_add(bytes_processed as usize, Ordering::Relaxed);
435 }
436 Err(e) => {
437 println!(" ❌ {} ({})", file_path.display(), e);
438 }
439 }
440 }
441 }
442
443 if let Some(ref mut output) = output_file {
444 writeln!(
445 output,
446 "### DIRECTORY {} FLATTENED CONTENT ###",
447 base_folder.display()
448 )?;
449 }
450
451 pb.finish_with_message("Done");
452 }
453
454 if !any_folder_found {
455 return Ok(());
456 }
457
458 println!();
459 println!("{} Flatten completed successfully!", style("✓").green());
460 let total = total_files.load(Ordering::Relaxed);
461 println!("Total files processed: {}", total);
462
463 if config.show_stats {
464 print_stats(total, total_bytes_processed.load(Ordering::Relaxed) as u64);
465 }
466
467 if !config.dry_run {
468 println!("Output written to: {}", args.output.display());
469 }
470
471 Ok(())
472}
473
474fn print_stats(total_files: usize, total_bytes: u64) {
476 const KB: f64 = 1024.0;
477 const MB: f64 = 1_048_576.0;
478
479 let bytes_str = if total_bytes as f64 >= MB {
480 format!("{:.2} MB", total_bytes as f64 / MB)
481 } else if total_bytes as f64 >= KB {
482 format!("{:.2} KB", total_bytes as f64 / KB)
483 } else {
484 format!("{} bytes", total_bytes)
485 };
486 println!("Total bytes processed: {}", bytes_str);
487
488 if total_files > 0 {
489 let avg_size = total_bytes / total_files as u64;
490 let avg_str = if avg_size as f64 >= KB {
491 format!("{:.2} KB", avg_size as f64 / KB)
492 } else {
493 format!("{} bytes", avg_size)
494 };
495 println!("Average file size: {}", avg_str);
496 }
497}
498
499fn collect_files(directory: &Path, config: &FlattenConfig) -> Result<Vec<PathBuf>> {
501 let mut files = Vec::new();
502 let mut walkdir = WalkDir::new(directory).follow_links(false);
503
504 if config.max_depth > 0 {
505 walkdir = walkdir.max_depth(config.max_depth);
506 }
507
508 for entry in walkdir
509 .into_iter()
510 .filter_entry(|e| !config.should_skip_path(e.path()))
511 {
512 let entry = entry?;
513 if entry.file_type().is_file() {
514 files.push(entry.path().to_path_buf());
515 }
516 }
517 Ok(files)
518}
519
520fn print_folder_structure<W: Write>(
522 directory: &Path,
523 writer: &mut W,
524 config: &FlattenConfig,
525) -> Result<()> {
526 writeln!(
527 writer,
528 "### DIRECTORY {} FOLDER STRUCTURE ###",
529 directory.display()
530 )?;
531
532 let mut walkdir = WalkDir::new(directory).follow_links(false);
533 if config.max_depth > 0 {
534 walkdir = walkdir.max_depth(config.max_depth);
535 }
536
537 for entry in walkdir.into_iter().filter_entry(|e| {
538 if e.file_type().is_dir() {
539 !config.should_skip_path(e.path()) || config.show_skipped
540 } else {
541 !config.should_skip_file(e.path())
542 }
543 }) {
544 let entry = entry?;
545 let path = entry.path();
546 let depth = entry.depth();
547 if depth == 0 {
548 continue;
549 }
550
551 let indent = " ".repeat(depth - 1);
552 let file_name = path.file_name().unwrap_or_else(|| OsStr::new(""));
553
554 if entry.file_type().is_dir() {
555 if config.should_skip_path(path) {
556 writeln!(
557 writer,
558 "{}{} {}/ (skipped)",
559 indent,
560 SKIP,
561 file_name.to_string_lossy()
562 )?;
563 } else {
564 writeln!(
565 writer,
566 "{}{} {}/",
567 indent,
568 FOLDER,
569 file_name.to_string_lossy()
570 )?;
571 }
572 } else {
573 writeln!(
574 writer,
575 "{}{} {}",
576 indent,
577 FILE,
578 file_name.to_string_lossy()
579 )?;
580 }
581 }
582
583 writeln!(
584 writer,
585 "### DIRECTORY {} FOLDER STRUCTURE ###\n",
586 directory.display()
587 )?;
588 Ok(())
589}
590
591fn read_file_content_fast(path: &Path, max_size: u64) -> Result<(String, u64)> {
593 let file =
594 File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
595 let metadata = file
596 .metadata()
597 .with_context(|| format!("Failed to read metadata: {}", path.display()))?;
598 let file_size = metadata.len();
599
600 if max_size > 0 && file_size > max_size {
601 return Ok((
602 format!("[File too large: {} bytes]", file_size),
603 file_size,
604 ));
605 }
606 if file_size == 0 {
607 return Ok((String::new(), 0));
608 }
609
610 let mmap = unsafe {
614 MmapOptions::new()
615 .map(&file)
616 .with_context(|| format!("Failed to memory map file: {}", path.display()))?
617 };
618
619 let content =
620 String::from_utf8(mmap.to_vec()).unwrap_or_else(|_| String::from_utf8_lossy(&mmap).into());
621
622 Ok((content, file_size))
623}
624
625fn process_files_parallel(
627 files: Vec<PathBuf>,
628 config: &FlattenConfig,
629 progress_bar: Option<ProgressBar>,
630) -> Vec<(PathBuf, Result<(String, u64)>)> {
631 let processed_count = AtomicUsize::new(0);
632
633 files
634 .into_par_iter()
635 .map(|file_path| {
636 let result = if config.should_skip_file(&file_path) {
637 Ok((
638 format!("[Binary file skipped: {}]", file_path.display()),
639 0,
640 ))
641 } else {
642 read_file_content_fast(&file_path, config.max_file_size)
643 };
644
645 let count = processed_count.fetch_add(1, Ordering::Relaxed);
646 if let Some(pb) = &progress_bar {
647 pb.set_position(count as u64 + 1);
648 }
649
650 (file_path, result)
651 })
652 .collect()
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use std::fs;
659 use tempfile::TempDir;
660
661 fn create_test_structure() -> Result<TempDir> {
663 let temp_dir = tempfile::tempdir()?;
664 fs::create_dir_all(temp_dir.path().join("src"))?;
665 fs::create_dir_all(temp_dir.path().join(".hidden_dir"))?;
666 fs::write(
667 temp_dir.path().join("src/main.rs"),
668 "fn main() {}",
669 )?;
670 fs::write(temp_dir.path().join(".hidden_file.txt"), "hidden")?;
671 fs::write(temp_dir.path().join("config.exe"), "binary")?;
672 Ok(temp_dir)
673 }
674
675 #[tokio::test]
676 async fn test_config_skip_path() -> Result<()> {
677 let temp_dir = create_test_structure()?;
678 let args = Args::parse_from([
679 "flatten-rust",
680 "-f",
681 temp_dir.path().to_str().expect("path is utf8"),
682 "-s",
683 "skip_me",
684 ]);
685 let config = FlattenConfig::new(&args).await?;
686
687 assert!(config.should_skip_path(Path::new("skip_me")));
688 assert!(!config.should_skip_path(Path::new("src")));
689 assert!(config.should_skip_path(Path::new(".hidden_dir")));
691 Ok(())
692 }
693
694 #[tokio::test]
695 async fn test_config_include_hidden() -> Result<()> {
696 let temp_dir = create_test_structure()?;
697 let args = Args::parse_from([
698 "flatten-rust",
699 "-f",
700 temp_dir.path().to_str().expect("path is utf8"),
701 "--include-hidden",
702 ]);
703 let config = FlattenConfig::new(&args).await?;
704
705 assert!(!config.should_skip_path(Path::new(".hidden_dir")));
706 Ok(())
707 }
708
709 #[tokio::test]
710 async fn test_config_skip_file() -> Result<()> {
711 let temp_dir = create_test_structure()?;
712 let args = Args::parse_from([
713 "flatten-rust",
714 "-f",
715 temp_dir.path().to_str().expect("path is utf8"),
716 "-x",
717 "exe",
718 ]);
719 let config = FlattenConfig::new(&args).await?;
720
721 assert!(config.should_skip_file(Path::new("some.exe")));
722 assert!(!config.should_skip_file(Path::new("main.rs")));
723 Ok(())
724 }
725}