git_editor/utils/
simulation.rs

1use crate::args::Args;
2use crate::utils::types::{CommitInfo, Result};
3use chrono::NaiveDateTime;
4use colored::Colorize;
5
6#[derive(Debug, Clone)]
7pub struct SimulationChange {
8    pub commit_oid: git2::Oid,
9    pub short_hash: String,
10    pub original_author: String,
11    pub original_email: String,
12    pub original_timestamp: NaiveDateTime,
13    pub original_message: String,
14    pub new_author: Option<String>,
15    pub new_email: Option<String>,
16    pub new_timestamp: Option<NaiveDateTime>,
17    pub new_message: Option<String>,
18}
19
20#[derive(Debug)]
21pub struct SimulationStats {
22    pub total_commits: usize,
23    pub commits_to_change: usize,
24    pub authors_changed: usize,
25    pub emails_changed: usize,
26    pub timestamps_changed: usize,
27    pub messages_changed: usize,
28    pub date_range_start: Option<NaiveDateTime>,
29    pub date_range_end: Option<NaiveDateTime>,
30}
31
32#[derive(Debug)]
33pub struct SimulationResult {
34    pub changes: Vec<SimulationChange>,
35    pub stats: SimulationStats,
36    pub operation_mode: String,
37}
38
39impl SimulationChange {
40    pub fn has_changes(&self) -> bool {
41        self.new_author.is_some()
42            || self.new_email.is_some()
43            || self.new_timestamp.is_some()
44            || self.new_message.is_some()
45    }
46
47    pub fn get_change_summary(&self) -> Vec<String> {
48        let mut changes = Vec::new();
49
50        if let Some(ref new_author) = self.new_author {
51            if new_author != &self.original_author {
52                changes.push(format!(
53                    "Author: {} → {}",
54                    self.original_author.red(),
55                    new_author.green()
56                ));
57            }
58        }
59
60        if let Some(ref new_email) = self.new_email {
61            if new_email != &self.original_email {
62                changes.push(format!(
63                    "Email: {} → {}",
64                    self.original_email.red(),
65                    new_email.green()
66                ));
67            }
68        }
69
70        if let Some(ref new_timestamp) = self.new_timestamp {
71            if new_timestamp != &self.original_timestamp {
72                changes.push(format!(
73                    "Date: {} → {}",
74                    self.original_timestamp
75                        .format("%Y-%m-%d %H:%M:%S")
76                        .to_string()
77                        .red(),
78                    new_timestamp
79                        .format("%Y-%m-%d %H:%M:%S")
80                        .to_string()
81                        .green()
82                ));
83            }
84        }
85
86        if let Some(ref new_message) = self.new_message {
87            let original_first_line = self.original_message.lines().next().unwrap_or("");
88            let new_first_line = new_message.lines().next().unwrap_or("");
89            if new_first_line != original_first_line {
90                changes.push(format!(
91                    "Message: {} → {}",
92                    original_first_line.red(),
93                    new_first_line.green()
94                ));
95            }
96        }
97
98        changes
99    }
100}
101
102impl SimulationStats {
103    pub fn new(commits: &[CommitInfo]) -> Self {
104        let total_commits = commits.len();
105        let (date_range_start, date_range_end) = if !commits.is_empty() {
106            let mut timestamps: Vec<_> = commits.iter().map(|c| c.timestamp).collect();
107            timestamps.sort();
108            (timestamps.first().copied(), timestamps.last().copied())
109        } else {
110            (None, None)
111        };
112
113        Self {
114            total_commits,
115            commits_to_change: 0,
116            authors_changed: 0,
117            emails_changed: 0,
118            timestamps_changed: 0,
119            messages_changed: 0,
120            date_range_start,
121            date_range_end,
122        }
123    }
124
125    pub fn update_from_changes(&mut self, changes: &[SimulationChange]) {
126        self.commits_to_change = changes.iter().filter(|c| c.has_changes()).count();
127
128        for change in changes {
129            if change.new_author.is_some() {
130                self.authors_changed += 1;
131            }
132            if change.new_email.is_some() {
133                self.emails_changed += 1;
134            }
135            if change.new_timestamp.is_some() {
136                self.timestamps_changed += 1;
137            }
138            if change.new_message.is_some() {
139                self.messages_changed += 1;
140            }
141        }
142    }
143
144    pub fn print_summary(&self, operation_mode: &str) {
145        println!("\n{}", "📊 SIMULATION SUMMARY".bold().cyan());
146        println!("{}", "=".repeat(50).cyan());
147
148        println!("{}: {}", "Operation Mode".bold(), operation_mode.yellow());
149        println!(
150            "{}: {}",
151            "Total Commits".bold(),
152            self.total_commits.to_string().cyan()
153        );
154        println!(
155            "{}: {}",
156            "Commits to Change".bold(),
157            if self.commits_to_change > 0 {
158                self.commits_to_change.to_string().yellow()
159            } else {
160                self.commits_to_change.to_string().green()
161            }
162        );
163
164        if self.commits_to_change > 0 {
165            println!("\n{}", "Changes Breakdown:".bold());
166            if self.authors_changed > 0 {
167                println!(
168                    "  • {} commits will have author names changed",
169                    self.authors_changed.to_string().yellow()
170                );
171            }
172            if self.emails_changed > 0 {
173                println!(
174                    "  • {} commits will have author emails changed",
175                    self.emails_changed.to_string().yellow()
176                );
177            }
178            if self.timestamps_changed > 0 {
179                println!(
180                    "  • {} commits will have timestamps changed",
181                    self.timestamps_changed.to_string().yellow()
182                );
183            }
184            if self.messages_changed > 0 {
185                println!(
186                    "  • {} commits will have messages changed",
187                    self.messages_changed.to_string().yellow()
188                );
189            }
190        }
191
192        if let (Some(start), Some(end)) = (self.date_range_start, self.date_range_end) {
193            println!("\n{}", "Date Range:".bold());
194            println!(
195                "  {} → {}",
196                start.format("%Y-%m-%d %H:%M:%S").to_string().blue(),
197                end.format("%Y-%m-%d %H:%M:%S").to_string().blue()
198            );
199        }
200
201        if self.commits_to_change == 0 {
202            println!(
203                "\n{}",
204                "✅ No changes would be made with current parameters."
205                    .green()
206                    .bold()
207            );
208        } else {
209            println!(
210                "\n{}",
211                "⚠️  This is a simulation - no actual changes have been made."
212                    .yellow()
213                    .bold()
214            );
215            println!(
216                "{}",
217                "   Run without --simulate to apply these changes.".bright_black()
218            );
219        }
220    }
221}
222
223pub fn create_full_rewrite_simulation(
224    commits: &[CommitInfo],
225    timestamps: &[NaiveDateTime],
226    args: &Args,
227) -> Result<SimulationResult> {
228    let mut changes = Vec::new();
229    let new_author = args.name.as_ref().unwrap();
230    let new_email = args.email.as_ref().unwrap();
231
232    for (i, commit) in commits.iter().enumerate() {
233        let new_timestamp = timestamps.get(i).copied();
234
235        let change = SimulationChange {
236            commit_oid: commit.oid,
237            short_hash: commit.short_hash.clone(),
238            original_author: commit.author_name.clone(),
239            original_email: commit.author_email.clone(),
240            original_timestamp: commit.timestamp,
241            original_message: commit.message.clone(),
242            new_author: Some(new_author.clone()),
243            new_email: Some(new_email.clone()),
244            new_timestamp,
245            new_message: None, // Full rewrite doesn't change messages
246        };
247
248        changes.push(change);
249    }
250
251    let mut stats = SimulationStats::new(commits);
252    stats.update_from_changes(&changes);
253
254    Ok(SimulationResult {
255        changes,
256        stats,
257        operation_mode: "Full Repository Rewrite".to_string(),
258    })
259}
260
261pub fn create_range_simulation(
262    commits: &[CommitInfo],
263    selected_range: (usize, usize),
264    range_timestamps: &[NaiveDateTime],
265    args: &Args,
266) -> Result<SimulationResult> {
267    let mut changes = Vec::new();
268    let (start_idx, end_idx) = selected_range;
269
270    for (i, commit) in commits.iter().enumerate() {
271        let change = if i >= start_idx && i <= end_idx {
272            let timestamp_idx = i - start_idx;
273            let new_timestamp = range_timestamps.get(timestamp_idx).copied();
274
275            SimulationChange {
276                commit_oid: commit.oid,
277                short_hash: commit.short_hash.clone(),
278                original_author: commit.author_name.clone(),
279                original_email: commit.author_email.clone(),
280                original_timestamp: commit.timestamp,
281                original_message: commit.message.clone(),
282                new_author: args.name.clone(),
283                new_email: args.email.clone(),
284                new_timestamp,
285                new_message: None,
286            }
287        } else {
288            // Commits outside range remain unchanged
289            SimulationChange {
290                commit_oid: commit.oid,
291                short_hash: commit.short_hash.clone(),
292                original_author: commit.author_name.clone(),
293                original_email: commit.author_email.clone(),
294                original_timestamp: commit.timestamp,
295                original_message: commit.message.clone(),
296                new_author: None,
297                new_email: None,
298                new_timestamp: None,
299                new_message: None,
300            }
301        };
302
303        changes.push(change);
304    }
305
306    let mut stats = SimulationStats::new(commits);
307    stats.update_from_changes(&changes);
308
309    Ok(SimulationResult {
310        changes,
311        stats,
312        operation_mode: format!("Range Edit (commits {}-{})", start_idx + 1, end_idx + 1),
313    })
314}
315
316pub fn create_specific_commit_simulation(
317    commits: &[CommitInfo],
318    selected_commit_idx: usize,
319    new_author: Option<String>,
320    new_email: Option<String>,
321    new_timestamp: Option<NaiveDateTime>,
322    new_message: Option<String>,
323) -> Result<SimulationResult> {
324    let mut changes = Vec::new();
325
326    for (i, commit) in commits.iter().enumerate() {
327        let change = if i == selected_commit_idx {
328            SimulationChange {
329                commit_oid: commit.oid,
330                short_hash: commit.short_hash.clone(),
331                original_author: commit.author_name.clone(),
332                original_email: commit.author_email.clone(),
333                original_timestamp: commit.timestamp,
334                original_message: commit.message.clone(),
335                new_author: new_author.clone(),
336                new_email: new_email.clone(),
337                new_timestamp,
338                new_message: new_message.clone(),
339            }
340        } else {
341            // Other commits remain unchanged
342            SimulationChange {
343                commit_oid: commit.oid,
344                short_hash: commit.short_hash.clone(),
345                original_author: commit.author_name.clone(),
346                original_email: commit.author_email.clone(),
347                original_timestamp: commit.timestamp,
348                original_message: commit.message.clone(),
349                new_author: None,
350                new_email: None,
351                new_timestamp: None,
352                new_message: None,
353            }
354        };
355
356        changes.push(change);
357    }
358
359    let mut stats = SimulationStats::new(commits);
360    stats.update_from_changes(&changes);
361
362    Ok(SimulationResult {
363        changes,
364        stats,
365        operation_mode: "Specific Commit Edit".to_string(),
366    })
367}
368
369pub fn print_detailed_diff(result: &SimulationResult) {
370    println!("\n{}", "📋 DETAILED CHANGE PREVIEW".bold().cyan());
371    println!("{}", "=".repeat(70).cyan());
372
373    let changes_to_show: Vec<_> = result.changes.iter().filter(|c| c.has_changes()).collect();
374
375    if changes_to_show.is_empty() {
376        println!("{}", "No changes to display.".green());
377        return;
378    }
379
380    for (i, change) in changes_to_show.iter().enumerate() {
381        println!(
382            "\n{} {} {} ({})",
383            format!("{}.", i + 1).bold(),
384            "Commit".bold(),
385            change.short_hash.yellow().bold(),
386            change.commit_oid.to_string()[..16]
387                .to_string()
388                .bright_black()
389        );
390
391        let change_summary = change.get_change_summary();
392        for summary_line in change_summary {
393            println!("   {summary_line}");
394        }
395
396        if i < changes_to_show.len() - 1 {
397            println!("{}", "─".repeat(50).bright_black());
398        }
399    }
400
401    println!(
402        "\n{}",
403        format!(
404            "Showing {} changes out of {} total commits",
405            changes_to_show.len(),
406            result.changes.len()
407        )
408        .bright_black()
409    );
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use chrono::NaiveDateTime;
416
417    fn create_test_commit(
418        oid_str: &str,
419        author: &str,
420        email: &str,
421        timestamp_str: &str,
422        message: &str,
423    ) -> CommitInfo {
424        CommitInfo {
425            oid: git2::Oid::from_str(oid_str).unwrap(),
426            short_hash: oid_str[..8].to_string(),
427            timestamp: NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S").unwrap(),
428            author_name: author.to_string(),
429            author_email: email.to_string(),
430            message: message.to_string(),
431            parent_count: 1,
432        }
433    }
434
435    #[test]
436    fn test_simulation_change_has_changes() {
437        let commit = create_test_commit(
438            "1234567890abcdef1234567890abcdef12345678",
439            "Test User",
440            "test@example.com",
441            "2023-01-01 10:00:00",
442            "Test commit",
443        );
444
445        let change = SimulationChange {
446            commit_oid: commit.oid,
447            short_hash: commit.short_hash,
448            original_author: commit.author_name,
449            original_email: commit.author_email,
450            original_timestamp: commit.timestamp,
451            original_message: commit.message,
452            new_author: Some("New Author".to_string()),
453            new_email: None,
454            new_timestamp: None,
455            new_message: None,
456        };
457
458        assert!(change.has_changes());
459    }
460
461    #[test]
462    fn test_simulation_change_no_changes() {
463        let commit = create_test_commit(
464            "1234567890abcdef1234567890abcdef12345678",
465            "Test User",
466            "test@example.com",
467            "2023-01-01 10:00:00",
468            "Test commit",
469        );
470
471        let change = SimulationChange {
472            commit_oid: commit.oid,
473            short_hash: commit.short_hash,
474            original_author: commit.author_name,
475            original_email: commit.author_email,
476            original_timestamp: commit.timestamp,
477            original_message: commit.message,
478            new_author: None,
479            new_email: None,
480            new_timestamp: None,
481            new_message: None,
482        };
483
484        assert!(!change.has_changes());
485    }
486
487    #[test]
488    fn test_simulation_stats_creation() {
489        let commits = vec![
490            create_test_commit(
491                "1234567890abcdef1234567890abcdef12345678",
492                "User1",
493                "user1@example.com",
494                "2023-01-01 10:00:00",
495                "First commit",
496            ),
497            create_test_commit(
498                "abcdef1234567890abcdef1234567890abcdef12",
499                "User2",
500                "user2@example.com",
501                "2023-01-02 15:30:00",
502                "Second commit",
503            ),
504        ];
505
506        let stats = SimulationStats::new(&commits);
507
508        assert_eq!(stats.total_commits, 2);
509        assert_eq!(stats.commits_to_change, 0);
510        assert!(stats.date_range_start.is_some());
511        assert!(stats.date_range_end.is_some());
512    }
513
514    #[test]
515    fn test_create_full_rewrite_simulation() {
516        let commits = vec![create_test_commit(
517            "1234567890abcdef1234567890abcdef12345678",
518            "Old User",
519            "old@example.com",
520            "2023-01-01 10:00:00",
521            "First commit",
522        )];
523
524        let timestamps =
525            vec![
526                NaiveDateTime::parse_from_str("2023-06-01 09:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
527            ];
528
529        let args = Args {
530            repo_path: Some("./test".to_string()),
531            email: Some("new@example.com".to_string()),
532            name: Some("New User".to_string()),
533            start: Some("2023-06-01 08:00:00".to_string()),
534            end: Some("2023-06-01 18:00:00".to_string()),
535            show_history: false,
536            pick_specific_commits: false,
537            range: false,
538            simulate: true,
539            show_diff: false,
540            edit_message: false,
541            edit_author: false,
542            edit_time: false,
543        };
544
545        let result = create_full_rewrite_simulation(&commits, &timestamps, &args).unwrap();
546
547        assert_eq!(result.changes.len(), 1);
548        assert_eq!(result.stats.total_commits, 1);
549        assert_eq!(result.stats.commits_to_change, 1);
550        assert_eq!(result.operation_mode, "Full Repository Rewrite");
551
552        let change = &result.changes[0];
553        assert!(change.has_changes());
554        assert_eq!(change.new_author.as_ref().unwrap(), "New User");
555        assert_eq!(change.new_email.as_ref().unwrap(), "new@example.com");
556    }
557
558    #[test]
559    fn test_create_specific_commit_simulation() {
560        let commits = vec![
561            create_test_commit(
562                "1234567890abcdef1234567890abcdef12345678",
563                "User1",
564                "user1@example.com",
565                "2023-01-01 10:00:00",
566                "First commit",
567            ),
568            create_test_commit(
569                "abcdef1234567890abcdef1234567890abcdef12",
570                "User2",
571                "user2@example.com",
572                "2023-01-02 15:30:00",
573                "Second commit",
574            ),
575        ];
576
577        let result = create_specific_commit_simulation(
578            &commits,
579            0, // Edit first commit
580            Some("New Author".to_string()),
581            Some("new@example.com".to_string()),
582            None,
583            Some("Updated message".to_string()),
584        )
585        .unwrap();
586
587        assert_eq!(result.changes.len(), 2);
588        assert_eq!(result.stats.commits_to_change, 1);
589
590        // First commit should have changes
591        assert!(result.changes[0].has_changes());
592        assert_eq!(result.changes[0].new_author.as_ref().unwrap(), "New Author");
593
594        // Second commit should not have changes
595        assert!(!result.changes[1].has_changes());
596    }
597}