Skip to main content

rs_hack/
diff.rs

1//! Unified diff generation and display for showing changes
2//! before applying them (dry-run mode).
3
4use std::path::Path;
5use similar::{ChangeTag, TextDiff};
6
7/// Represents statistics about a diff
8#[derive(Debug, Default)]
9pub struct DiffStats {
10    pub files_changed: usize,
11    pub lines_added: usize,
12    pub lines_removed: usize,
13}
14
15impl DiffStats {
16    pub fn add(&mut self, other: &DiffStats) {
17        self.files_changed += other.files_changed;
18        self.lines_added += other.lines_added;
19        self.lines_removed += other.lines_removed;
20    }
21
22    pub fn print_summary(&self) {
23        println!("\nSummary:");
24        println!("Files changed: {}", self.files_changed);
25        println!("Lines added: {}", self.lines_added);
26        println!("Lines removed: {}", self.lines_removed);
27    }
28}
29
30/// Generate a unified diff between original and modified content
31///
32/// Returns the unified diff string and statistics about the changes.
33///
34/// # Arguments
35/// * `path` - The file path (used in diff headers)
36/// * `original` - The original file content
37/// * `modified` - The modified file content
38/// * `context_lines` - Number of context lines to show (default is 3)
39pub fn generate_unified_diff(
40    path: &Path,
41    original: &str,
42    modified: &str,
43    context_lines: usize,
44) -> (String, DiffStats) {
45    let diff = TextDiff::from_lines(original, modified);
46
47    let mut output = String::new();
48    let mut stats = DiffStats::default();
49
50    // Generate unified diff format headers
51    let path_str = path.display().to_string();
52    output.push_str(&format!("--- {}\n", path_str));
53    output.push_str(&format!("+++ {}\n", path_str));
54
55    // Count changes for statistics
56    for change in diff.iter_all_changes() {
57        match change.tag() {
58            ChangeTag::Insert => stats.lines_added += 1,
59            ChangeTag::Delete => stats.lines_removed += 1,
60            ChangeTag::Equal => {}
61        }
62    }
63
64    // Generate the unified diff with context
65    let unified = diff.unified_diff()
66        .context_radius(context_lines)
67        .to_string();
68
69    output.push_str(&unified);
70
71    if stats.lines_added > 0 || stats.lines_removed > 0 {
72        stats.files_changed = 1;
73    }
74
75    (output, stats)
76}
77
78/// Print a unified diff to stdout
79///
80/// This is a convenience function that generates and prints a diff.
81///
82/// # Arguments
83/// * `path` - The file path
84/// * `original` - The original file content
85/// * `modified` - The modified file content
86///
87/// Returns statistics about the diff.
88pub fn print_diff(path: &Path, original: &str, modified: &str) -> DiffStats {
89    let (diff_output, stats) = generate_unified_diff(path, original, modified, 3);
90
91    // Only print if there are actual changes
92    if stats.files_changed > 0 {
93        print!("{}", diff_output);
94    }
95
96    stats
97}
98
99/// Print a summary of changes (only changed lines with minimal context)
100///
101/// This shows a more focused view than a full unified diff, displaying only
102/// the lines that changed with their line numbers.
103///
104/// # Arguments
105/// * `path` - The file path
106/// * `original` - The original file content
107/// * `modified` - The modified file content
108///
109/// Returns statistics about the diff.
110pub fn print_summary_diff(path: &Path, original: &str, modified: &str) -> DiffStats {
111    use similar::{ChangeTag, TextDiff};
112
113    let diff = TextDiff::from_lines(original, modified);
114    let mut stats = DiffStats::default();
115    let mut changes = Vec::new();
116
117    // Collect all changes with their line numbers
118    let mut current_line = 1;
119    for change in diff.iter_all_changes() {
120        match change.tag() {
121            ChangeTag::Delete => {
122                changes.push((current_line, '-', change.to_string()));
123                stats.lines_removed += 1;
124                current_line += 1;
125            }
126            ChangeTag::Insert => {
127                changes.push((current_line, '+', change.to_string()));
128                stats.lines_added += 1;
129            }
130            ChangeTag::Equal => {
131                current_line += 1;
132            }
133        }
134    }
135
136    if !changes.is_empty() {
137        stats.files_changed = 1;
138
139        println!("\nšŸ“ Changes for {}:\n", path.display());
140
141        // Group consecutive changes together
142        let mut i = 0;
143        while i < changes.len() {
144            let (line_num, tag, content) = &changes[i];
145
146            // Print the change
147            if *tag == '-' {
148                print!("{:>5} | {}{}", line_num, tag, content);
149            } else {
150                print!("{:>5} | {}{}", line_num, tag, content);
151            }
152
153            i += 1;
154        }
155
156        println!("\nāœ“ {} changes in {}", changes.len(), path.display());
157    }
158
159    stats
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::path::PathBuf;
166
167    #[test]
168    fn test_generate_unified_diff() {
169        let original = "pub struct User {\n    id: u64,\n    name: String,\n}\n";
170        let modified = "pub struct User {\n    id: u64,\n    age: u32,\n    name: String,\n}\n";
171        let path = PathBuf::from("src/user.rs");
172
173        let (diff, stats) = generate_unified_diff(&path, original, modified, 3);
174
175        // Check that diff contains expected headers
176        assert!(diff.contains("--- src/user.rs"));
177        assert!(diff.contains("+++ src/user.rs"));
178
179        // Check that diff contains the added line
180        assert!(diff.contains("+    age: u32,"));
181
182        // Check statistics
183        assert_eq!(stats.files_changed, 1);
184        assert_eq!(stats.lines_added, 1);
185        assert_eq!(stats.lines_removed, 0);
186    }
187
188    #[test]
189    fn test_generate_unified_diff_no_changes() {
190        let content = "pub struct User {\n    id: u64,\n}\n";
191        let path = PathBuf::from("src/user.rs");
192
193        let (diff, stats) = generate_unified_diff(&path, content, content, 3);
194
195        // Should have headers but no hunks
196        assert!(diff.contains("--- src/user.rs"));
197        assert!(diff.contains("+++ src/user.rs"));
198
199        // Statistics should show no changes
200        assert_eq!(stats.files_changed, 0);
201        assert_eq!(stats.lines_added, 0);
202        assert_eq!(stats.lines_removed, 0);
203    }
204
205    #[test]
206    fn test_generate_unified_diff_with_removal() {
207        let original = "pub struct User {\n    id: u64,\n    name: String,\n    email: String,\n}\n";
208        let modified = "pub struct User {\n    id: u64,\n    name: String,\n}\n";
209        let path = PathBuf::from("src/user.rs");
210
211        let (diff, stats) = generate_unified_diff(&path, original, modified, 3);
212
213        // Check that diff contains the removed line
214        assert!(diff.contains("-    email: String,"));
215
216        // Check statistics
217        assert_eq!(stats.files_changed, 1);
218        assert_eq!(stats.lines_added, 0);
219        assert_eq!(stats.lines_removed, 1);
220    }
221
222    #[test]
223    fn test_diff_stats_add() {
224        let mut stats1 = DiffStats {
225            files_changed: 1,
226            lines_added: 5,
227            lines_removed: 2,
228        };
229
230        let stats2 = DiffStats {
231            files_changed: 2,
232            lines_added: 3,
233            lines_removed: 1,
234        };
235
236        stats1.add(&stats2);
237
238        assert_eq!(stats1.files_changed, 3);
239        assert_eq!(stats1.lines_added, 8);
240        assert_eq!(stats1.lines_removed, 3);
241    }
242
243    #[test]
244    fn test_print_diff_returns_stats() {
245        let original = "line1\nline2\n";
246        let modified = "line1\nline2\nline3\n";
247        let path = PathBuf::from("test.txt");
248
249        let stats = print_diff(&path, original, modified);
250
251        assert_eq!(stats.files_changed, 1);
252        assert_eq!(stats.lines_added, 1);
253        assert_eq!(stats.lines_removed, 0);
254    }
255}