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