Skip to main content

ripsed_core/
diff.rs

1use serde::{Deserialize, Serialize};
2
3/// A single change within a file.
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct Change {
6    /// 1-indexed line number where the change occurs.
7    pub line: usize,
8    /// The original line content.
9    pub before: String,
10    /// The modified line content (None for deletions).
11    pub after: Option<String>,
12    /// Surrounding context lines.
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub context: Option<ChangeContext>,
15}
16
17/// Context lines surrounding a change for display.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct ChangeContext {
20    pub before: Vec<String>,
21    pub after: Vec<String>,
22}
23
24/// All changes applied to a single file.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct FileChanges {
27    pub path: String,
28    pub changes: Vec<Change>,
29}
30
31/// The result of applying an operation, including all file changes.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct OpResult {
34    pub operation_index: usize,
35    pub files: Vec<FileChanges>,
36}
37
38/// Summary statistics for the full run.
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40pub struct Summary {
41    pub files_matched: usize,
42    pub files_modified: usize,
43    pub total_replacements: usize,
44}
45
46/// Compute an aggregate summary from a slice of `OpResult`s.
47///
48/// `files_matched` counts the number of unique file paths that appear in any result.
49/// `files_modified` counts the number of unique file paths that have at least one change.
50/// `total_replacements` counts the total number of individual changes across all files.
51pub fn compute_summary(results: &[OpResult]) -> Summary {
52    use std::collections::HashSet;
53
54    let mut matched_paths: HashSet<&str> = HashSet::new();
55    let mut modified_paths: HashSet<&str> = HashSet::new();
56    let mut total_replacements: usize = 0;
57
58    for result in results {
59        for file_changes in &result.files {
60            matched_paths.insert(&file_changes.path);
61            if !file_changes.changes.is_empty() {
62                modified_paths.insert(&file_changes.path);
63                total_replacements += file_changes.changes.len();
64            }
65        }
66    }
67
68    Summary {
69        files_matched: matched_paths.len(),
70        files_modified: modified_paths.len(),
71        total_replacements,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn test_compute_summary_empty() {
81        let results: Vec<OpResult> = vec![];
82        let summary = compute_summary(&results);
83        assert_eq!(
84            summary,
85            Summary {
86                files_matched: 0,
87                files_modified: 0,
88                total_replacements: 0,
89            }
90        );
91    }
92
93    #[test]
94    fn test_compute_summary_single_result() {
95        let results = vec![OpResult {
96            operation_index: 0,
97            files: vec![FileChanges {
98                path: "src/main.rs".to_string(),
99                changes: vec![
100                    Change {
101                        line: 1,
102                        before: "old".to_string(),
103                        after: Some("new".to_string()),
104                        context: None,
105                    },
106                    Change {
107                        line: 5,
108                        before: "old2".to_string(),
109                        after: Some("new2".to_string()),
110                        context: None,
111                    },
112                ],
113            }],
114        }];
115        let summary = compute_summary(&results);
116        assert_eq!(
117            summary,
118            Summary {
119                files_matched: 1,
120                files_modified: 1,
121                total_replacements: 2,
122            }
123        );
124    }
125
126    #[test]
127    fn test_compute_summary_multiple_files() {
128        let results = vec![
129            OpResult {
130                operation_index: 0,
131                files: vec![
132                    FileChanges {
133                        path: "a.rs".to_string(),
134                        changes: vec![Change {
135                            line: 1,
136                            before: "x".to_string(),
137                            after: Some("y".to_string()),
138                            context: None,
139                        }],
140                    },
141                    FileChanges {
142                        path: "b.rs".to_string(),
143                        changes: vec![Change {
144                            line: 2,
145                            before: "x".to_string(),
146                            after: Some("y".to_string()),
147                            context: None,
148                        }],
149                    },
150                ],
151            },
152            OpResult {
153                operation_index: 1,
154                files: vec![FileChanges {
155                    path: "a.rs".to_string(),
156                    changes: vec![Change {
157                        line: 3,
158                        before: "z".to_string(),
159                        after: Some("w".to_string()),
160                        context: None,
161                    }],
162                }],
163            },
164        ];
165        let summary = compute_summary(&results);
166        assert_eq!(
167            summary,
168            Summary {
169                files_matched: 2,
170                files_modified: 2,
171                total_replacements: 3,
172            }
173        );
174    }
175
176    #[test]
177    fn test_compute_summary_file_with_no_changes() {
178        let results = vec![OpResult {
179            operation_index: 0,
180            files: vec![FileChanges {
181                path: "empty.rs".to_string(),
182                changes: vec![],
183            }],
184        }];
185        let summary = compute_summary(&results);
186        assert_eq!(
187            summary,
188            Summary {
189                files_matched: 1,
190                files_modified: 0,
191                total_replacements: 0,
192            }
193        );
194    }
195
196    #[test]
197    fn test_compute_summary_deletions_counted() {
198        let results = vec![OpResult {
199            operation_index: 0,
200            files: vec![FileChanges {
201                path: "file.rs".to_string(),
202                changes: vec![
203                    Change {
204                        line: 1,
205                        before: "deleted line".to_string(),
206                        after: None, // deletion
207                        context: None,
208                    },
209                    Change {
210                        line: 3,
211                        before: "replaced".to_string(),
212                        after: Some("new".to_string()),
213                        context: None,
214                    },
215                ],
216            }],
217        }];
218        let summary = compute_summary(&results);
219        assert_eq!(summary.total_replacements, 2);
220    }
221}