flatten_rust/
lib.rs

1//! # Flatten Rust (Library)
2//!
3//! Этот крейт предоставляет основную функциональность для утилиты `flatten-rust`,
4//! инструмента для "сглаживания" кодовых баз в единый markdown-файл.
5//! Он включает в себя логику для обхода директорий, фильтрации файлов
6//! на основе шаблонов исключений, параллельной обработки и форматирования вывода.
7//!
8//! ## Основные компоненты:
9//!
10//! - `Args`: Структура для парсинга аргументов командной строки с использованием `clap`.
11//! - `run`: Асинхронная функция, являющаяся основной точкой входа в библиотеку.
12//! - `FlattenConfig`: Структура для управления конфигурацией процесса "сглаживания".
13//! - `config`: Модуль для управления шаблонами исключений (например, из `.gitignore`).
14//! - `exclusions`: Модуль для управления логикой исключения файлов и папок.
15//!
16//! # Примеры
17//!
18//! Хотя этот крейт в основном предназначен для использования через CLI,
19//! его компоненты могут быть использованы и программно.
20//!
21//! ```no_run
22//! use flatten_rust::Args;
23//! use anyhow::Result;
24//! use clap::Parser;
25//!
26//! #[tokio::main]
27//! async fn main() -> Result<()> {
28//!     // Пример парсинга аргументов и запуска
29//!     let args = Args::parse_from(["flatten-rust", "-f", ".", "-d"]);
30//!     flatten_rust::run(&args).await?;
31//!     Ok(())
32//! }
33//! ```
34
35pub 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/// # Высокопроизводительный инструмент для "сглаживания" кодовой базы с умными исключениями
61///
62/// Утилита для рекурсивного обхода директорий, конкатенации текстовых файлов
63/// в один Markdown-файл с сохранением структуры проекта.
64#[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    /// Базовые папки для обработки
97    #[arg(long = "folders", short = 'f', num_args = 1..)]
98    pub folders: Vec<PathBuf>,
99
100    /// Папки для пропуска при обработке (поддерживаются glob-паттерны)
101    #[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    /// Выходной файл
107    #[arg(long = "output", short = 'o', default_value = "codebase.md")]
108    pub output: PathBuf,
109
110    /// Показывать пропущенные папки в дереве структуры
111    #[arg(long = "show-skipped", short = 'k')]
112    pub show_skipped: bool,
113
114    /// Количество потоков для параллельной обработки файлов
115    #[arg(long = "threads", short = 't', default_value = "0")]
116    pub threads: usize,
117
118    /// Максимальный размер файла для обработки в байтах (0 = без ограничений)
119    #[arg(long = "max-file-size", short = 'm', default_value = "104857600")]
120    pub max_file_size: u64,
121
122    /// Паттерны расширений файлов для пропуска
123    #[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    /// Автоматически определять тип проекта и настраивать соответствующие пропуски
129    #[arg(long = "auto-detect", short = 'a')]
130    pub auto_detect: bool,
131
132    /// Включать скрытые файлы и папки
133    #[arg(long = "include-hidden")]
134    pub include_hidden: bool,
135
136    /// Максимальная глубина обхода директорий (0 = без ограничений)
137    #[arg(long = "max-depth", default_value = "0")]
138    pub max_depth: usize,
139
140    /// Показать детальную статистику после обработки
141    #[arg(long = "stats", short = 'S')]
142    pub show_stats: bool,
143
144    /// Тестовый запуск - показать, что будет обработано, без создания выходного файла
145    #[arg(long = "dry-run", short = 'd')]
146    pub dry_run: bool,
147
148    /// Показать список всех доступных шаблонов исключений
149    #[arg(long = "list-templates", short = 'l')]
150    pub list_templates: bool,
151
152    /// Включить определенный шаблон исключений
153    #[arg(long = "enable-template", short = 'e', num_args = 1..)]
154    pub enable_templates: Vec<String>,
155
156    /// Отключить определенный шаблон исключений
157    #[arg(long = "disable-template", short = 'D', num_args = 1..)]
158    pub disable_templates: Vec<String>,
159
160    /// Принудительно обновить шаблоны из API
161    #[arg(long = "force-update", short = 'u')]
162    pub force_update: bool,
163
164    /// Показать включенные шаблоны
165    #[arg(long = "show-enabled")]
166    pub show_enabled: bool,
167}
168
169/// Конфигурация процесса "сглаживания".
170///
171/// Содержит все параметры, необходимые для управления процессом,
172/// включая правила исключений, лимиты и флаги форматирования.
173#[derive(Debug)]
174pub struct FlattenConfig {
175    /// Менеджер для работы с шаблонами исключений.
176    exclusion_manager: ExclusionManager,
177    /// Набор папок для пропуска.
178    skip_folders: HashSet<String>,
179    /// Набор расширений файлов для пропуска.
180    skip_extensions: HashSet<String>,
181    /// Показывать ли пропущенные элементы в выводе.
182    show_skipped: bool,
183    /// Максимальный размер файла для обработки.
184    max_file_size: u64,
185    /// Включать ли скрытые файлы и папки.
186    include_hidden: bool,
187    /// Максимальная глубина рекурсии.
188    max_depth: usize,
189    /// Показывать ли статистику в конце.
190    show_stats: bool,
191    /// Выполнять ли тестовый запуск.
192    dry_run: bool,
193}
194
195impl FlattenConfig {
196    /// Создает новый экземпляр `FlattenConfig` на основе аргументов командной строки.
197    ///
198    /// Асинхронно инициализирует `ExclusionManager`, загружает и обновляет шаблоны
199    /// исключений, а также обрабатывает команды управления шаблонами.
200    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    /// Обрабатывает команду вывода списка доступных шаблонов.
257    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    /// Обрабатывает команду вывода списка включенных шаблонов.
270    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    /// Проверяет, следует ли пропустить данный путь (директорию).
283    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    /// Проверяет, следует ли пропустить данный файл (по расширению).
295    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
304/// Основная функция-точка входа для запуска процесса "сглаживания".
305///
306/// # Аргументы
307/// * `args` - Ссылка на структуру `Args` с параметрами командной строки.
308///
309/// # Ошибки
310/// Возвращает ошибку, если возникают проблемы с файловыми операциями,
311/// настройкой потоков или обработкой данных.
312pub 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
474/// Выводит статистику по завершении работы.
475fn 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
499/// Рекурсивно собирает пути ко всем файлам в директории, учитывая конфигурацию.
500fn 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
520/// Выводит в `writer` древовидную структуру директории.
521fn 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
591/// Эффективно читает содержимое файла, используя memory-mapping.
592fn 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    // SAFETY: Memory mapping a file is safe. The file is read-only, and the lifetime
611    // of the mmap is tied to the function's scope, ensuring the file handle outlives it.
612    // The underlying file is not modified while the map is active.
613    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
625/// Обрабатывает список файлов в параллельном режиме.
626fn 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    /// Создает временную структуру директорий и файлов для тестов.
662    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        // Тест скрытых файлов
690        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}