Skip to main content

reformat_core/
combined.rs

1//! Combined processing for multiple transformations in a single pass
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7use crate::{
8    CaseTransform, EmojiOptions, EmojiTransformer, FileRenamer, RenameOptions, WhitespaceCleaner,
9    WhitespaceOptions,
10};
11
12/// Options for combined processing
13#[derive(Debug, Clone)]
14pub struct CombinedOptions {
15    /// Process directories recursively
16    pub recursive: bool,
17    /// Dry run mode (don't modify files)
18    pub dry_run: bool,
19}
20
21impl Default for CombinedOptions {
22    fn default() -> Self {
23        CombinedOptions {
24            recursive: true,
25            dry_run: false,
26        }
27    }
28}
29
30/// Statistics from combined processing
31#[derive(Debug, Default)]
32pub struct CombinedStats {
33    /// Number of files renamed
34    pub files_renamed: usize,
35    /// Number of files with emoji transformations
36    pub files_emoji_transformed: usize,
37    /// Number of emoji changes
38    pub emoji_changes: usize,
39    /// Number of files with whitespace cleaned
40    pub files_whitespace_cleaned: usize,
41    /// Number of lines with whitespace cleaned
42    pub whitespace_lines_cleaned: usize,
43}
44
45/// Combined processor that applies multiple transformations in a single pass
46pub struct CombinedProcessor {
47    options: CombinedOptions,
48    rename_options: RenameOptions,
49    emoji_options: EmojiOptions,
50    whitespace_options: WhitespaceOptions,
51}
52
53impl CombinedProcessor {
54    /// Creates a new combined processor with the given options
55    pub fn new(options: CombinedOptions) -> Self {
56        let rename_options = RenameOptions {
57            case_transform: CaseTransform::Lowercase,
58            recursive: options.recursive,
59            dry_run: options.dry_run,
60            ..Default::default()
61        };
62
63        let emoji_options = EmojiOptions {
64            recursive: options.recursive,
65            dry_run: options.dry_run,
66            ..Default::default()
67        };
68
69        let whitespace_options = WhitespaceOptions {
70            recursive: options.recursive,
71            dry_run: options.dry_run,
72            ..Default::default()
73        };
74
75        CombinedProcessor {
76            options,
77            rename_options,
78            emoji_options,
79            whitespace_options,
80        }
81    }
82
83    /// Creates a processor with default options
84    pub fn with_defaults() -> Self {
85        CombinedProcessor::new(CombinedOptions::default())
86    }
87
88    /// Processes a directory or file with all transformations
89    pub fn process(&self, path: &Path) -> crate::Result<CombinedStats> {
90        let mut stats = CombinedStats::default();
91
92        if path.is_file() {
93            self.process_single_file(path, &mut stats)?;
94        } else if path.is_dir() {
95            if self.options.recursive {
96                // Collect all files first to avoid iterator invalidation during renames
97                let mut files: Vec<PathBuf> = WalkDir::new(path)
98                    .into_iter()
99                    .filter_map(|e| e.ok())
100                    .filter(|e| e.file_type().is_file())
101                    .map(|e| e.path().to_path_buf())
102                    .collect();
103
104                // Sort by depth (deepest first) to avoid parent directory rename issues
105                files.sort_by_key(|b| std::cmp::Reverse(b.components().count()));
106
107                for file_path in files {
108                    self.process_single_file(&file_path, &mut stats)?;
109                }
110            } else {
111                let mut files: Vec<PathBuf> = fs::read_dir(path)?
112                    .filter_map(|e| e.ok())
113                    .map(|e| e.path())
114                    .filter(|p| p.is_file())
115                    .collect();
116
117                // Sort for consistent processing
118                files.sort();
119
120                for file_path in files {
121                    self.process_single_file(&file_path, &mut stats)?;
122                }
123            }
124        }
125
126        Ok(stats)
127    }
128
129    /// Processes a single file with all transformations
130    fn process_single_file(&self, path: &Path, stats: &mut CombinedStats) -> crate::Result<()> {
131        // Step 1: Rename file (lowercase)
132        // Combined processor only handles regular files, not symlinks
133        let renamer = FileRenamer::new(self.rename_options.clone());
134        let renamed = renamer.rename_file(path, false)?;
135        if renamed {
136            stats.files_renamed += 1;
137        }
138
139        // Determine the current path (may have been renamed)
140        let current_path = if renamed && !self.options.dry_run {
141            // Calculate the new path after renaming
142            let file_name = path
143                .file_name()
144                .and_then(|n| n.to_str())
145                .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
146
147            let lowercase_name = file_name.to_lowercase();
148            let parent = path
149                .parent()
150                .ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
151            parent.join(lowercase_name)
152        } else {
153            path.to_path_buf()
154        };
155
156        // Step 2: Transform emojis
157        let emoji_transformer = EmojiTransformer::new(self.emoji_options.clone());
158        let emoji_changes = emoji_transformer.transform_file(&current_path)?;
159        if emoji_changes > 0 {
160            stats.files_emoji_transformed += 1;
161            stats.emoji_changes += emoji_changes;
162        }
163
164        // Step 3: Clean whitespace
165        let whitespace_cleaner = WhitespaceCleaner::new(self.whitespace_options.clone());
166        let lines_cleaned = whitespace_cleaner.clean_file(&current_path)?;
167        if lines_cleaned > 0 {
168            stats.files_whitespace_cleaned += 1;
169            stats.whitespace_lines_cleaned += lines_cleaned;
170        }
171
172        Ok(())
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::fs;
180
181    #[test]
182    fn test_combined_processing() {
183        let test_dir = std::env::temp_dir().join("reformat_combined_test");
184        let _ = fs::remove_dir_all(&test_dir);
185        fs::create_dir_all(&test_dir).unwrap();
186
187        // Create a file with uppercase name, emojis, and trailing whitespace
188        let test_file = test_dir.join("TestFile.txt");
189        fs::write(&test_file, "Line 1   \nTask done ✅\nLine 3\t\n").unwrap();
190
191        let processor = CombinedProcessor::with_defaults();
192        let stats = processor.process(&test_file).unwrap();
193
194        // File should be renamed
195        assert_eq!(stats.files_renamed, 1);
196        let renamed_file = test_dir.join("testfile.txt");
197        assert!(renamed_file.exists());
198
199        // Emojis should be transformed
200        assert_eq!(stats.files_emoji_transformed, 1);
201        let content = fs::read_to_string(&renamed_file).unwrap();
202        assert!(content.contains("[x]"));
203        assert!(!content.contains("✅"));
204
205        // Whitespace should be cleaned
206        assert_eq!(stats.files_whitespace_cleaned, 1);
207        assert!(!content.contains("   \n"));
208        assert!(!content.contains("\t\n"));
209
210        fs::remove_dir_all(&test_dir).unwrap();
211    }
212
213    #[test]
214    fn test_combined_dry_run() {
215        let test_dir = std::env::temp_dir().join("reformat_combined_dry");
216        let _ = fs::remove_dir_all(&test_dir);
217        fs::create_dir_all(&test_dir).unwrap();
218
219        let test_file = test_dir.join("TestFile.txt");
220        let original_content = "Line 1   \nTask ✅\n";
221        fs::write(&test_file, original_content).unwrap();
222
223        let mut options = CombinedOptions::default();
224        options.dry_run = true;
225
226        let processor = CombinedProcessor::new(options);
227        let _stats = processor.process(&test_file).unwrap();
228
229        // File should remain unchanged in dry run
230        assert!(test_file.exists());
231        let content = fs::read_to_string(&test_file).unwrap();
232        assert_eq!(content, original_content);
233
234        fs::remove_dir_all(&test_dir).unwrap();
235    }
236
237    #[test]
238    fn test_combined_recursive() {
239        let test_dir = std::env::temp_dir().join("reformat_combined_recursive");
240        let _ = fs::remove_dir_all(&test_dir);
241        fs::create_dir_all(&test_dir).unwrap();
242
243        let sub_dir = test_dir.join("subdir");
244        fs::create_dir_all(&sub_dir).unwrap();
245
246        let file1 = test_dir.join("File1.txt");
247        let file2 = sub_dir.join("File2.md");
248
249        fs::write(&file1, "Text   \n✅ Done\n").unwrap();
250        fs::write(&file2, "More text\t\n☐ Todo\n").unwrap();
251
252        let processor = CombinedProcessor::with_defaults();
253        let stats = processor.process(&test_dir).unwrap();
254
255        // Both files should be processed
256        assert_eq!(stats.files_renamed, 2);
257        assert_eq!(stats.files_emoji_transformed, 2);
258        assert_eq!(stats.files_whitespace_cleaned, 2);
259
260        // Check renamed files exist
261        assert!(test_dir.join("file1.txt").exists());
262        assert!(sub_dir.join("file2.md").exists());
263
264        fs::remove_dir_all(&test_dir).unwrap();
265    }
266
267    #[test]
268    fn test_combined_non_recursive() {
269        let test_dir = std::env::temp_dir().join("reformat_combined_nonrec");
270        let _ = fs::remove_dir_all(&test_dir);
271        fs::create_dir_all(&test_dir).unwrap();
272
273        let sub_dir = test_dir.join("subdir");
274        fs::create_dir_all(&sub_dir).unwrap();
275
276        let file1 = test_dir.join("File1.txt");
277        let file2 = sub_dir.join("File2.txt");
278
279        fs::write(&file1, "Text   \n").unwrap();
280        fs::write(&file2, "More   \n").unwrap();
281
282        let mut options = CombinedOptions::default();
283        options.recursive = false;
284
285        let processor = CombinedProcessor::new(options);
286        let stats = processor.process(&test_dir).unwrap();
287
288        // Only top-level file should be processed
289        assert_eq!(stats.files_renamed, 1);
290        assert!(test_dir.join("file1.txt").exists());
291
292        // Check that subdirectory file was NOT renamed (should still be File2.txt)
293        // On case-insensitive filesystems, both paths refer to the same file, so check actual filename
294        let entries: Vec<_> = fs::read_dir(&sub_dir).unwrap().collect();
295        assert_eq!(entries.len(), 1);
296        let actual_name = entries[0].as_ref().unwrap().file_name();
297        assert_eq!(
298            actual_name.to_str().unwrap(),
299            "File2.txt",
300            "Subdirectory file should not be renamed"
301        );
302
303        fs::remove_dir_all(&test_dir).unwrap();
304    }
305}