ricecoder_generation/
output_writer.rs

1//! Output writer for generated code files
2//!
3//! Writes generated code to files with rollback capability, dry-run mode, and conflict resolution.
4//! Implements requirements:
5//! - Requirement 1.6: Write generated code to files
6//! - Requirement 3.1: Dry-run mode (preview without writing)
7//! - Requirement 3.5: Rollback support (restore on failure)
8//! - Requirement 4.2, 4.3, 4.4: Conflict resolution strategies
9
10use crate::conflict_detector::FileConflictInfo;
11use crate::conflict_resolver::{ConflictResolver, ConflictStrategy};
12use crate::error::GenerationError;
13use crate::models::GeneratedFile;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17/// Configuration for output writing
18#[derive(Debug, Clone)]
19pub struct OutputWriterConfig {
20    /// Whether to run in dry-run mode (preview only)
21    pub dry_run: bool,
22    /// Whether to create backups before writing
23    pub create_backups: bool,
24    /// Whether to format code before writing
25    pub format_code: bool,
26    /// Default conflict resolution strategy
27    pub conflict_strategy: ConflictStrategy,
28}
29
30impl Default for OutputWriterConfig {
31    fn default() -> Self {
32        Self {
33            dry_run: false,
34            create_backups: true,
35            format_code: true,
36            conflict_strategy: ConflictStrategy::Skip,
37        }
38    }
39}
40
41/// Result of writing a single file
42#[derive(Debug, Clone)]
43pub struct FileWriteResult {
44    /// Path to the file that was written
45    pub path: PathBuf,
46    /// Whether the file was actually written
47    pub written: bool,
48    /// Path to backup file if created
49    pub backup_path: Option<PathBuf>,
50    /// Action taken (e.g., "Written", "Skipped", "Merged")
51    pub action: String,
52    /// Whether this was a dry-run
53    pub dry_run: bool,
54}
55
56/// Result of writing multiple files
57#[derive(Debug, Clone)]
58pub struct WriteResult {
59    /// Results for each file
60    pub files: Vec<FileWriteResult>,
61    /// Total files written
62    pub files_written: usize,
63    /// Total files skipped
64    pub files_skipped: usize,
65    /// Total backups created
66    pub backups_created: usize,
67    /// Whether this was a dry-run
68    pub dry_run: bool,
69    /// Rollback information if needed
70    pub rollback_info: Option<RollbackInfo>,
71}
72
73/// Information for rolling back changes
74#[derive(Debug, Clone)]
75pub struct RollbackInfo {
76    /// Backups that were created
77    pub backups: Vec<(PathBuf, PathBuf)>, // (original_path, backup_path)
78    /// Files that were written
79    pub written_files: Vec<PathBuf>,
80}
81
82/// Writes generated code to files with rollback capability
83///
84/// Implements requirements:
85/// - Requirement 1.6: Write generated code to files
86/// - Requirement 3.1: Dry-run mode (preview without writing)
87/// - Requirement 3.5: Rollback support (restore on failure)
88/// - Requirement 4.2, 4.3, 4.4: Conflict resolution strategies
89pub struct OutputWriter {
90    config: OutputWriterConfig,
91    conflict_resolver: ConflictResolver,
92}
93
94impl OutputWriter {
95    /// Create a new output writer with default configuration
96    pub fn new() -> Self {
97        Self {
98            config: OutputWriterConfig::default(),
99            conflict_resolver: ConflictResolver::new(),
100        }
101    }
102
103    /// Create a new output writer with custom configuration
104    pub fn with_config(config: OutputWriterConfig) -> Self {
105        Self {
106            config,
107            conflict_resolver: ConflictResolver::new(),
108        }
109    }
110
111    /// Write generated files to disk
112    ///
113    /// Writes all files atomically. If any write fails, rolls back all changes.
114    ///
115    /// # Arguments
116    /// * `files` - Generated files to write
117    /// * `target_dir` - Target directory where files should be written
118    /// * `conflicts` - Detected file conflicts
119    ///
120    /// # Returns
121    /// Write result with information about what was written
122    ///
123    /// # Requirements
124    /// - Requirement 1.6: Write generated code to files
125    /// - Requirement 3.1: Dry-run mode (preview without writing)
126    /// - Requirement 3.5: Rollback support (restore on failure)
127    pub fn write(
128        &self,
129        files: &[GeneratedFile],
130        target_dir: &Path,
131        conflicts: &[FileConflictInfo],
132    ) -> Result<WriteResult, GenerationError> {
133        let mut file_results = Vec::new();
134        let mut backups = Vec::new();
135        let mut written_files = Vec::new();
136        let mut files_written = 0;
137        let mut files_skipped = 0;
138        let mut backups_created = 0;
139
140        // Process each file
141        for file in files {
142            let file_path = target_dir.join(&file.path);
143
144            // Create parent directories if needed
145            if let Some(parent) = file_path.parent() {
146                if !parent.exists() && !self.config.dry_run {
147                    fs::create_dir_all(parent).map_err(|e| {
148                        GenerationError::WriteFailed(format!(
149                            "Failed to create directory {}: {}",
150                            parent.display(),
151                            e
152                        ))
153                    })?;
154                }
155            }
156
157            // Check for conflicts
158            let conflict = conflicts.iter().find(|c| c.path == file_path);
159
160            let result = if let Some(conflict) = conflict {
161                // Handle conflict
162                self.handle_conflict(&file_path, conflict, &file.content, &mut backups)?
163            } else {
164                // No conflict, write file
165                self.write_file(&file_path, &file.content, &mut backups)?
166            };
167
168            if result.written {
169                files_written += 1;
170                written_files.push(file_path.clone());
171            } else {
172                files_skipped += 1;
173            }
174
175            if result.backup_path.is_some() {
176                backups_created += 1;
177            }
178
179            file_results.push(result);
180        }
181
182        // If dry-run, don't actually write anything
183        if self.config.dry_run {
184            return Ok(WriteResult {
185                files: file_results,
186                files_written: 0,
187                files_skipped: files.len(),
188                backups_created: 0,
189                dry_run: true,
190                rollback_info: None,
191            });
192        }
193
194        // If any write failed, rollback
195        if files_written > 0 && files_written < files.len() {
196            self.rollback(&backups, &written_files)?;
197            return Err(GenerationError::WriteFailed(
198                "Partial write detected, rolled back all changes".to_string(),
199            ));
200        }
201
202        let rollback_info = if !backups.is_empty() {
203            Some(RollbackInfo {
204                backups: backups.clone(),
205                written_files: written_files.clone(),
206            })
207        } else {
208            None
209        };
210
211        Ok(WriteResult {
212            files: file_results,
213            files_written,
214            files_skipped,
215            backups_created,
216            dry_run: false,
217            rollback_info,
218        })
219    }
220
221    /// Write a single file
222    ///
223    /// # Arguments
224    /// * `file_path` - Path to write to
225    /// * `content` - File content
226    /// * `backups` - List to track backups created
227    ///
228    /// # Returns
229    /// Write result for this file
230    fn write_file(
231        &self,
232        file_path: &Path,
233        content: &str,
234        backups: &mut Vec<(PathBuf, PathBuf)>,
235    ) -> Result<FileWriteResult, GenerationError> {
236        // Create backup if file exists and backups are enabled
237        let backup_path = if file_path.exists() && self.config.create_backups {
238            let backup = self.create_backup(file_path)?;
239            backups.push((file_path.to_path_buf(), backup.clone()));
240            Some(backup)
241        } else {
242            None
243        };
244
245        // Write file (unless dry-run)
246        if !self.config.dry_run {
247            let content_to_write = if self.config.format_code {
248                self.format_code(content, file_path)?
249            } else {
250                content.to_string()
251            };
252
253            fs::write(file_path, content_to_write).map_err(|e| {
254                GenerationError::WriteFailed(format!(
255                    "Failed to write {}: {}",
256                    file_path.display(),
257                    e
258                ))
259            })?;
260        }
261
262        let has_backup = backup_path.is_some();
263        Ok(FileWriteResult {
264            path: file_path.to_path_buf(),
265            written: true,
266            backup_path,
267            action: if has_backup {
268                "Written (backup created)".to_string()
269            } else {
270                "Written".to_string()
271            },
272            dry_run: self.config.dry_run,
273        })
274    }
275
276    /// Handle a file conflict
277    ///
278    /// # Arguments
279    /// * `file_path` - Path to the conflicting file
280    /// * `conflict` - Conflict information
281    /// * `new_content` - New content to write
282    /// * `backups` - List to track backups created
283    ///
284    /// # Returns
285    /// Write result for this file
286    fn handle_conflict(
287        &self,
288        file_path: &Path,
289        conflict: &FileConflictInfo,
290        new_content: &str,
291        backups: &mut Vec<(PathBuf, PathBuf)>,
292    ) -> Result<FileWriteResult, GenerationError> {
293        // Resolve conflict using configured strategy
294        let resolution =
295            self.conflict_resolver
296                .resolve(conflict, self.config.conflict_strategy, new_content)?;
297
298        // Track backup if created
299        if let Some(backup_path) = &resolution.backup_path {
300            backups.push((file_path.to_path_buf(), PathBuf::from(backup_path)));
301        }
302
303        Ok(FileWriteResult {
304            path: file_path.to_path_buf(),
305            written: resolution.written,
306            backup_path: resolution.backup_path.map(PathBuf::from),
307            action: resolution.action,
308            dry_run: self.config.dry_run,
309        })
310    }
311
312    /// Create a backup of a file
313    ///
314    /// # Arguments
315    /// * `file_path` - Path to file to backup
316    ///
317    /// # Returns
318    /// Path to backup file
319    fn create_backup(&self, file_path: &Path) -> Result<PathBuf, GenerationError> {
320        let backup_path = format!("{}.bak", file_path.display());
321        let backup_path_obj = PathBuf::from(&backup_path);
322
323        if !self.config.dry_run {
324            let content = fs::read_to_string(file_path).map_err(|e| {
325                GenerationError::WriteFailed(format!("Failed to read file for backup: {}", e))
326            })?;
327
328            fs::write(&backup_path_obj, content).map_err(|e| {
329                GenerationError::WriteFailed(format!("Failed to create backup: {}", e))
330            })?;
331        }
332
333        Ok(backup_path_obj)
334    }
335
336    /// Format code before writing
337    ///
338    /// # Arguments
339    /// * `content` - Code content to format
340    /// * `_file_path` - Path to file (used to determine language)
341    ///
342    /// # Returns
343    /// Formatted code
344    fn format_code(&self, content: &str, _file_path: &Path) -> Result<String, GenerationError> {
345        // For now, just return content as-is
346        // In a real implementation, this would call language-specific formatters
347        Ok(content.to_string())
348    }
349
350    /// Rollback changes by restoring backups
351    ///
352    /// # Arguments
353    /// * `backups` - List of (original_path, backup_path) tuples
354    /// * `written_files` - List of files that were written
355    ///
356    /// # Returns
357    /// Result of rollback operation
358    fn rollback(
359        &self,
360        backups: &[(PathBuf, PathBuf)],
361        written_files: &[PathBuf],
362    ) -> Result<(), GenerationError> {
363        // Restore backups
364        for (original_path, backup_path) in backups {
365            if backup_path.exists() {
366                let backup_content = fs::read_to_string(backup_path).map_err(|e| {
367                    GenerationError::RollbackFailed(format!("Failed to read backup: {}", e))
368                })?;
369
370                fs::write(original_path, backup_content).map_err(|e| {
371                    GenerationError::RollbackFailed(format!("Failed to restore backup: {}", e))
372                })?;
373
374                // Remove backup file
375                fs::remove_file(backup_path).map_err(|e| {
376                    GenerationError::RollbackFailed(format!("Failed to remove backup: {}", e))
377                })?;
378            }
379        }
380
381        // Remove written files
382        for file_path in written_files {
383            if file_path.exists() {
384                fs::remove_file(file_path).map_err(|e| {
385                    GenerationError::RollbackFailed(format!("Failed to remove file: {}", e))
386                })?;
387            }
388        }
389
390        Ok(())
391    }
392
393    /// Preview changes without writing (dry-run mode)
394    ///
395    /// # Arguments
396    /// * `files` - Generated files to preview
397    /// * `target_dir` - Target directory
398    /// * `conflicts` - Detected conflicts
399    ///
400    /// # Returns
401    /// Write result showing what would be written
402    pub fn preview(
403        &self,
404        files: &[GeneratedFile],
405        target_dir: &Path,
406        conflicts: &[FileConflictInfo],
407    ) -> Result<WriteResult, GenerationError> {
408        let mut config = self.config.clone();
409        config.dry_run = true;
410
411        let writer = OutputWriter::with_config(config);
412        writer.write(files, target_dir, conflicts)
413    }
414
415    /// Get a summary of write results
416    ///
417    /// # Arguments
418    /// * `result` - Write result to summarize
419    ///
420    /// # Returns
421    /// Summary string
422    pub fn summarize_result(&self, result: &WriteResult) -> String {
423        format!(
424            "Files written: {}, Files skipped: {}, Backups created: {}{}",
425            result.files_written,
426            result.files_skipped,
427            result.backups_created,
428            if result.dry_run { " (dry-run)" } else { "" }
429        )
430    }
431}
432
433impl Default for OutputWriter {
434    fn default() -> Self {
435        Self::new()
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use tempfile::TempDir;
443
444    #[test]
445    fn test_create_output_writer() {
446        let _writer = OutputWriter::new();
447    }
448
449    #[test]
450    fn test_write_single_file() {
451        let temp_dir = TempDir::new().unwrap();
452        let writer = OutputWriter::new();
453
454        let files = vec![GeneratedFile {
455            path: "src/main.rs".to_string(),
456            content: "fn main() {}".to_string(),
457            language: "rust".to_string(),
458        }];
459
460        let result = writer.write(&files, temp_dir.path(), &[]).unwrap();
461
462        assert_eq!(result.files_written, 1);
463        assert_eq!(result.files_skipped, 0);
464        assert!(temp_dir.path().join("src/main.rs").exists());
465    }
466
467    #[test]
468    fn test_write_multiple_files() {
469        let temp_dir = TempDir::new().unwrap();
470        let writer = OutputWriter::new();
471
472        let files = vec![
473            GeneratedFile {
474                path: "src/main.rs".to_string(),
475                content: "fn main() {}".to_string(),
476                language: "rust".to_string(),
477            },
478            GeneratedFile {
479                path: "src/lib.rs".to_string(),
480                content: "pub fn lib() {}".to_string(),
481                language: "rust".to_string(),
482            },
483        ];
484
485        let result = writer.write(&files, temp_dir.path(), &[]).unwrap();
486
487        assert_eq!(result.files_written, 2);
488        assert_eq!(result.files_skipped, 0);
489        assert!(temp_dir.path().join("src/main.rs").exists());
490        assert!(temp_dir.path().join("src/lib.rs").exists());
491    }
492
493    #[test]
494    fn test_dry_run_mode() {
495        let temp_dir = TempDir::new().unwrap();
496        let config = OutputWriterConfig {
497            dry_run: true,
498            ..Default::default()
499        };
500        let writer = OutputWriter::with_config(config);
501
502        let files = vec![GeneratedFile {
503            path: "src/main.rs".to_string(),
504            content: "fn main() {}".to_string(),
505            language: "rust".to_string(),
506        }];
507
508        let result = writer.write(&files, temp_dir.path(), &[]).unwrap();
509
510        assert!(result.dry_run);
511        assert_eq!(result.files_written, 0);
512        assert_eq!(result.files_skipped, 1);
513        assert!(!temp_dir.path().join("src/main.rs").exists());
514    }
515
516    #[test]
517    fn test_create_backup() {
518        let temp_dir = TempDir::new().unwrap();
519        let writer = OutputWriter::new();
520
521        // Create existing file
522        let file_path = temp_dir.path().join("existing.rs");
523        fs::write(&file_path, "old content").unwrap();
524
525        let files = vec![GeneratedFile {
526            path: "existing.rs".to_string(),
527            content: "new content".to_string(),
528            language: "rust".to_string(),
529        }];
530
531        let result = writer.write(&files, temp_dir.path(), &[]).unwrap();
532
533        assert_eq!(result.files_written, 1);
534        assert_eq!(result.backups_created, 1);
535
536        // Verify backup was created
537        let backup_path = temp_dir.path().join("existing.rs.bak");
538        assert!(backup_path.exists());
539
540        let backup_content = fs::read_to_string(&backup_path).unwrap();
541        assert_eq!(backup_content, "old content");
542
543        // Verify new content was written
544        let new_content = fs::read_to_string(&file_path).unwrap();
545        assert_eq!(new_content, "new content");
546    }
547
548    #[test]
549    fn test_conflict_skip_strategy() {
550        let temp_dir = TempDir::new().unwrap();
551        let config = OutputWriterConfig {
552            conflict_strategy: ConflictStrategy::Skip,
553            ..Default::default()
554        };
555        let writer = OutputWriter::with_config(config);
556
557        // Create existing file
558        let file_path = temp_dir.path().join("existing.rs");
559        fs::write(&file_path, "old content").unwrap();
560
561        let files = vec![GeneratedFile {
562            path: "existing.rs".to_string(),
563            content: "new content".to_string(),
564            language: "rust".to_string(),
565        }];
566
567        // Create conflict
568        let conflict = FileConflictInfo {
569            path: file_path.clone(),
570            old_content: "old content".to_string(),
571            new_content: "new content".to_string(),
572            diff: crate::conflict_detector::FileDiff {
573                added_lines: vec![],
574                removed_lines: vec![],
575                modified_lines: vec![],
576                total_changes: 0,
577            },
578        };
579
580        let result = writer.write(&files, temp_dir.path(), &[conflict]).unwrap();
581
582        assert_eq!(result.files_written, 0);
583        assert_eq!(result.files_skipped, 1);
584
585        // Verify file was not overwritten
586        let content = fs::read_to_string(&file_path).unwrap();
587        assert_eq!(content, "old content");
588    }
589
590    #[test]
591    fn test_conflict_overwrite_strategy() {
592        let temp_dir = TempDir::new().unwrap();
593        let config = OutputWriterConfig {
594            conflict_strategy: ConflictStrategy::Overwrite,
595            ..Default::default()
596        };
597        let writer = OutputWriter::with_config(config);
598
599        // Create existing file
600        let file_path = temp_dir.path().join("existing.rs");
601        fs::write(&file_path, "old content").unwrap();
602
603        let files = vec![GeneratedFile {
604            path: "existing.rs".to_string(),
605            content: "new content".to_string(),
606            language: "rust".to_string(),
607        }];
608
609        // Create conflict
610        let conflict = FileConflictInfo {
611            path: file_path.clone(),
612            old_content: "old content".to_string(),
613            new_content: "new content".to_string(),
614            diff: crate::conflict_detector::FileDiff {
615                added_lines: vec![],
616                removed_lines: vec![],
617                modified_lines: vec![],
618                total_changes: 0,
619            },
620        };
621
622        let result = writer.write(&files, temp_dir.path(), &[conflict]).unwrap();
623
624        assert_eq!(result.files_written, 1);
625        assert_eq!(result.backups_created, 1);
626
627        // Verify file was overwritten
628        let content = fs::read_to_string(&file_path).unwrap();
629        assert_eq!(content, "new content");
630
631        // Verify backup was created
632        let backup_path = temp_dir.path().join("existing.rs.bak");
633        assert!(backup_path.exists());
634    }
635
636    #[test]
637    fn test_preview_mode() {
638        let temp_dir = TempDir::new().unwrap();
639        let writer = OutputWriter::new();
640
641        let files = vec![GeneratedFile {
642            path: "src/main.rs".to_string(),
643            content: "fn main() {}".to_string(),
644            language: "rust".to_string(),
645        }];
646
647        let result = writer.preview(&files, temp_dir.path(), &[]).unwrap();
648
649        assert!(result.dry_run);
650        assert_eq!(result.files_written, 0);
651        assert!(!temp_dir.path().join("src/main.rs").exists());
652    }
653
654    #[test]
655    fn test_summarize_result() {
656        let writer = OutputWriter::new();
657        let result = WriteResult {
658            files: vec![],
659            files_written: 5,
660            files_skipped: 2,
661            backups_created: 3,
662            dry_run: false,
663            rollback_info: None,
664        };
665
666        let summary = writer.summarize_result(&result);
667        assert!(summary.contains("5"));
668        assert!(summary.contains("2"));
669        assert!(summary.contains("3"));
670    }
671
672    #[test]
673    fn test_create_nested_directories() {
674        let temp_dir = TempDir::new().unwrap();
675        let writer = OutputWriter::new();
676
677        let files = vec![GeneratedFile {
678            path: "src/nested/deep/main.rs".to_string(),
679            content: "fn main() {}".to_string(),
680            language: "rust".to_string(),
681        }];
682
683        let result = writer.write(&files, temp_dir.path(), &[]).unwrap();
684
685        assert_eq!(result.files_written, 1);
686        assert!(temp_dir.path().join("src/nested/deep/main.rs").exists());
687    }
688}