Skip to main content

morph_cli/core/diff/
preview.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use anyhow::Result;
5
6use crate::utils::terminal;
7
8#[derive(Debug, Clone)]
9#[allow(dead_code)]
10pub struct FilePreview {
11    pub path: PathBuf,
12    pub original_content: String,
13    pub transformed_content: String,
14    pub hunks: Vec<DiffHunk>,
15    pub is_binary: bool,
16    pub was_truncated: bool,
17    pub line_count: usize,
18}
19
20#[derive(Debug, Clone)]
21pub struct DiffHunk {
22    pub old_start: usize,
23    pub old_count: usize,
24    pub new_start: usize,
25    pub new_count: usize,
26    pub lines: Vec<DiffLine>,
27}
28
29#[derive(Debug, Clone)]
30pub struct DiffLine {
31    pub content: String,
32    pub line_type: LineType,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[allow(dead_code)]
37pub enum LineType {
38    Context,
39    Addition,
40    Deletion,
41    Header,
42}
43
44#[derive(Debug, Clone)]
45pub struct TransformationReport {
46    pub changed_files: Vec<ChangedFile>,
47    pub skipped_files: Vec<SkippedFile>,
48    pub execution_time_ms: u64,
49    pub start_time: Instant,
50}
51
52impl TransformationReport {
53    pub fn new() -> Self {
54        Self {
55            changed_files: Vec::new(),
56            skipped_files: Vec::new(),
57            execution_time_ms: 0,
58            start_time: Instant::now(),
59        }
60    }
61
62    pub fn finish(&mut self) {
63        self.execution_time_ms = self.start_time.elapsed().as_millis() as u64;
64    }
65
66    pub fn total_changed(&self) -> usize {
67        self.changed_files.len()
68    }
69
70    pub fn total_lines_added(&self) -> usize {
71        self.changed_files.iter().map(|f| f.lines_added).sum()
72    }
73
74    pub fn total_lines_removed(&self) -> usize {
75        self.changed_files.iter().map(|f| f.lines_removed).sum()
76    }
77
78    pub fn total_files_skipped(&self) -> usize {
79        self.skipped_files.len()
80    }
81}
82
83#[derive(Debug, Clone)]
84pub struct ChangedFile {
85    pub path: PathBuf,
86    pub lines_added: usize,
87    pub lines_removed: usize,
88    pub preview: Option<FilePreview>,
89}
90
91#[derive(Debug, Clone)]
92pub struct SkippedFile {
93    pub path: PathBuf,
94    pub reason: SkipReason,
95}
96
97#[derive(Debug, Clone)]
98#[allow(dead_code)]
99pub enum SkipReason {
100    Binary,
101    Empty,
102    UnsupportedEncoding,
103    ExceedsMaxSize,
104    NoChanges,
105}
106
107impl std::fmt::Display for SkipReason {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            SkipReason::Binary => write!(f, "binary file"),
111            SkipReason::Empty => write!(f, "empty file"),
112            SkipReason::UnsupportedEncoding => write!(f, "unsupported encoding"),
113            SkipReason::ExceedsMaxSize => write!(f, "exceeds max size"),
114            SkipReason::NoChanges => write!(f, "no changes detected"),
115        }
116    }
117}
118
119#[derive(Debug, Clone, Copy)]
120#[allow(dead_code)]
121pub struct PreviewConfig {
122    pub max_lines: usize,
123    pub show_line_numbers: bool,
124    pub summary_only: bool,
125    pub verbose: bool,
126}
127
128impl PreviewConfig {
129    #[allow(dead_code)]
130    pub fn new(max_lines: usize) -> Self {
131        Self {
132            max_lines,
133            show_line_numbers: true,
134            summary_only: false,
135            verbose: false,
136        }
137    }
138}
139
140#[allow(dead_code)]
141pub fn create_preview(
142    path: &Path,
143    original: &str,
144    transformed: &str,
145    config: PreviewConfig,
146) -> Result<FilePreview> {
147    let is_binary = contains_binary_content(original);
148    let mut was_truncated = false;
149
150    let original_lines: Vec<&str> = if config.max_lines > 0 {
151        original.lines().take(config.max_lines * 2).collect()
152    } else {
153        original.lines().collect()
154    };
155
156    let line_count = original_lines.len();
157    if config.max_lines > 0 && line_count > config.max_lines * 2 {
158        was_truncated = true;
159    }
160
161    let hunks = if !is_binary && !original.is_empty() && !transformed.is_empty() {
162        compute_diff_hunks(
163            original_lines,
164            transformed.lines().collect::<Vec<_>>(),
165            &config,
166        )
167    } else {
168        Vec::new()
169    };
170
171    Ok(FilePreview {
172        path: path.to_path_buf(),
173        original_content: original.to_string(),
174        transformed_content: transformed.to_string(),
175        hunks,
176        is_binary,
177        was_truncated,
178        line_count,
179    })
180}
181
182#[allow(dead_code)]
183fn compute_diff_hunks(
184    old_lines: Vec<&str>,
185    new_lines: Vec<&str>,
186    config: &PreviewConfig,
187) -> Vec<DiffHunk> {
188    let mut hunks = Vec::new();
189    let max_lines = config.max_lines.saturating_add(1);
190
191    let old_max = if max_lines > 0 && old_lines.len() > max_lines {
192        max_lines
193    } else {
194        old_lines.len()
195    };
196
197    let new_max = if max_lines > 0 && new_lines.len() > max_lines {
198        max_lines
199    } else {
200        new_lines.len()
201    };
202
203    for i in 0..old_max {
204        if old_max == old_lines.len() && i < new_max && old_lines[i] != new_lines[i] {
205            let hunk = DiffHunk {
206                old_start: i + 1,
207                old_count: 1,
208                new_start: i + 1,
209                new_count: 1,
210                lines: vec![
211                    DiffLine {
212                        content: format!("- {}", old_lines[i]),
213                        line_type: LineType::Deletion,
214                    },
215                    DiffLine {
216                        content: format!("+ {}", new_lines[i]),
217                        line_type: LineType::Addition,
218                    },
219                ],
220            };
221            hunks.push(hunk);
222            break;
223        }
224    }
225
226    hunks
227}
228
229#[allow(dead_code)]
230fn contains_binary_content(content: &str) -> bool {
231    for byte in content.bytes() {
232        if byte == 0 {
233            return true;
234        }
235    }
236    false
237}
238
239#[allow(dead_code)]
240pub fn is_valid_utf8(content: &str) -> bool {
241    content.is_empty() || std::str::from_utf8(content.as_bytes()).is_ok()
242}
243
244#[allow(dead_code)]
245pub fn summarize_report(report: &TransformationReport) {
246    println!();
247    println!("{}", terminal::label("Transformation Summary"));
248    println!("  changed files: {}", report.total_changed());
249    println!("  lines added:   {}", report.total_lines_added());
250    println!("  lines removed: {}", report.total_lines_removed());
251    println!("  skipped files:  {}", report.total_files_skipped());
252    println!("  execution time: {}ms", report.execution_time_ms);
253    println!();
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ReviewAction {
258    Apply,
259    Skip,
260    Abort,
261}
262
263pub fn prompt_review(
264    path: &Path,
265    original: &str,
266    transformed: &str,
267    renderer: &crate::core::diff::renderer::DiffRenderer,
268) -> Result<ReviewAction> {
269    let preview = create_preview(
270        path,
271        original,
272        transformed,
273        crate::core::diff::preview::PreviewConfig {
274            max_lines: 100,
275            show_line_numbers: true,
276            summary_only: false,
277            verbose: false,
278        },
279    )?;
280
281    renderer.render_file_preview(&preview);
282
283    loop {
284        use std::io::Write;
285        use colored::Colorize;
286        println!();
287        println!("  ┌──────────────────────────────────────────────────────────┐");
288        println!("  │  🔍 {} {:<39} │", "Reviewing file:".dimmed(), path.display().to_string().bold().cyan());
289        println!("  ├──────────────────────────────────────────────────────────┤");
290        println!("  │  {} [y]  {:<39} │", "✔".green(), "Apply and write changes to disk safely".bold().green());
291        println!("  │  {} [n]  {:<39} │", "⚡".yellow(), "Skip changes for this file cleanly".bold().yellow());
292        println!("  │  {} [q]  {:<39} │", "❌".red(), "Abort the entire execution session safely".bold().red());
293        println!("  └──────────────────────────────────────────────────────────┘");
294        print!("👉 Choose an action (y/n/q): ");
295        std::io::stdout().flush()?;
296        let mut input = String::new();
297        std::io::stdin().read_line(&mut input)?;
298        let ans = input.trim().to_lowercase();
299        match ans.as_str() {
300            "y" | "yes" | "apply" => return Ok(ReviewAction::Apply),
301            "n" | "no" | "skip" => return Ok(ReviewAction::Skip),
302            "q" | "quit" | "abort" => return Ok(ReviewAction::Abort),
303            _ => println!("⚠️  {}", "Invalid choice. Please enter 'y', 'n', or 'q'.".red().bold()),
304        }
305    }
306}