Line data Source code
1 : use std::collections::HashSet;
2 : use std::hash::{Hash, Hasher};
3 : use std::path::PathBuf;
4 : use std::time::SystemTime;
5 :
6 : #[derive(Debug, Clone)]
7 : pub struct DiffSnapshot {
8 : #[allow(dead_code)]
9 : pub timestamp: SystemTime,
10 : pub files: Vec<FileChange>,
11 : }
12 :
13 : #[derive(Debug, Clone)]
14 : pub struct FileChange {
15 : pub path: PathBuf,
16 : pub status: String,
17 : pub hunks: Vec<Hunk>,
18 : }
19 :
20 : #[derive(Debug, Clone)]
21 : pub struct Hunk {
22 : pub old_start: usize,
23 : pub new_start: usize,
24 : pub lines: Vec<String>,
25 : pub seen: bool,
26 : pub staged: bool,
27 : /// Track which individual lines are staged (by index in lines vec)
28 : pub staged_line_indices: HashSet<usize>,
29 : pub id: HunkId,
30 : }
31 :
32 : impl Hunk {
33 : #[allow(dead_code)]
34 1 : pub fn format(&self) -> String {
35 1 : self.lines.join("")
36 1 : }
37 :
38 1 : pub fn count_changes(&self) -> usize {
39 1 : let mut add_lines = 0;
40 1 : let mut remove_lines = 0;
41 :
42 3 : for line in &self.lines {
43 3 : if line.starts_with('+') && !line.starts_with("+++") {
44 2 : add_lines += 1;
45 2 : } else if line.starts_with('-') && !line.starts_with("---") {
46 1 : remove_lines += 1;
47 1 : }
48 : }
49 :
50 : // Count pairs of add/remove as 1 change, plus any unpaired lines
51 1 : let pairs = add_lines.min(remove_lines);
52 1 : let unpaired = (add_lines + remove_lines) - (2 * pairs);
53 1 : pairs + unpaired
54 1 : }
55 :
56 19 : pub fn new(old_start: usize, new_start: usize, lines: Vec<String>, file_path: &PathBuf) -> Self {
57 19 : let id = HunkId::new(file_path, old_start, new_start, &lines);
58 19 : Self {
59 19 : old_start,
60 19 : new_start,
61 19 : lines,
62 19 : seen: false,
63 19 : staged: false,
64 19 : staged_line_indices: HashSet::new(),
65 19 : id,
66 19 : }
67 19 : }
68 : }
69 :
70 : /// Unique identifier for a hunk based on file path, line numbers, and content hash
71 : #[derive(Debug, Clone, PartialEq, Eq, Hash)]
72 : pub struct HunkId {
73 : pub file_path: PathBuf,
74 : pub old_start: usize,
75 : pub new_start: usize,
76 : pub content_hash: u64,
77 : }
78 :
79 : impl HunkId {
80 23 : pub fn new(file_path: &PathBuf, old_start: usize, new_start: usize, lines: &[String]) -> Self {
81 : use std::collections::hash_map::DefaultHasher;
82 :
83 23 : let mut hasher = DefaultHasher::new();
84 55 : for line in lines {
85 55 : line.hash(&mut hasher);
86 55 : }
87 23 : let content_hash = hasher.finish();
88 :
89 23 : Self {
90 23 : file_path: file_path.clone(),
91 23 : old_start,
92 23 : new_start,
93 23 : content_hash,
94 23 : }
95 23 : }
96 : }
97 :
98 : /// Tracks which hunks have been seen by the user
99 : #[derive(Debug, Clone)]
100 : pub struct SeenTracker {
101 : seen_hunks: HashSet<HunkId>,
102 : }
103 :
104 : impl SeenTracker {
105 2 : pub fn new() -> Self {
106 2 : Self {
107 2 : seen_hunks: HashSet::new(),
108 2 : }
109 2 : }
110 :
111 3 : pub fn mark_seen(&mut self, hunk_id: &HunkId) {
112 3 : self.seen_hunks.insert(hunk_id.clone());
113 3 : }
114 :
115 6 : pub fn is_seen(&self, hunk_id: &HunkId) -> bool {
116 6 : self.seen_hunks.contains(hunk_id)
117 6 : }
118 :
119 1 : pub fn clear(&mut self) {
120 1 : self.seen_hunks.clear();
121 1 : }
122 :
123 : #[allow(dead_code)]
124 1 : pub fn remove_file_hunks(&mut self, file_path: &PathBuf) {
125 1 : self.seen_hunks.retain(|hunk_id| &hunk_id.file_path != file_path);
126 1 : }
127 : }
128 :
129 : impl Default for SeenTracker {
130 1 : fn default() -> Self {
131 1 : Self::new()
132 1 : }
133 : }
134 :
135 : #[cfg(test)]
136 : mod tests {
137 : use super::*;
138 :
139 : #[test]
140 1 : fn count_changes_pairs_adds_and_removes() {
141 1 : let file_path = PathBuf::from("src/main.rs");
142 1 : let hunk = Hunk::new(
143 : 1,
144 : 1,
145 1 : vec![
146 1 : "-old line\n".to_string(),
147 1 : "+new line\n".to_string(),
148 1 : "+extra line\n".to_string(),
149 : ],
150 1 : &file_path,
151 : );
152 :
153 1 : assert_eq!(hunk.count_changes(), 2);
154 1 : }
155 :
156 : #[test]
157 1 : fn hunk_id_changes_when_content_changes() {
158 1 : let file_path = PathBuf::from("src/main.rs");
159 1 : let base = HunkId::new(&file_path, 10, 10, &["-a\n".to_string(), "+b\n".to_string()]);
160 1 : let changed =
161 1 : HunkId::new(&file_path, 10, 10, &["-a\n".to_string(), "+c\n".to_string()]);
162 :
163 1 : assert_ne!(base, changed);
164 1 : }
165 :
166 : #[test]
167 1 : fn seen_tracker_marks_and_clears_hunks() {
168 1 : let file_path = PathBuf::from("src/lib.rs");
169 1 : let hunk_id = HunkId::new(&file_path, 3, 3, &["+line\n".to_string()]);
170 1 : let mut tracker = SeenTracker::new();
171 :
172 1 : assert!(!tracker.is_seen(&hunk_id));
173 1 : tracker.mark_seen(&hunk_id);
174 1 : assert!(tracker.is_seen(&hunk_id));
175 :
176 1 : tracker.remove_file_hunks(&file_path);
177 1 : assert!(!tracker.is_seen(&hunk_id));
178 :
179 1 : tracker.mark_seen(&hunk_id);
180 1 : tracker.clear();
181 1 : assert!(!tracker.is_seen(&hunk_id));
182 1 : }
183 :
184 : #[test]
185 1 : fn hunk_format_and_constructor_defaults() {
186 1 : let file_path = PathBuf::from("src/main.rs");
187 1 : let lines = vec![" context\n".to_string(), "+added\n".to_string()];
188 1 : let hunk = Hunk::new(4, 7, lines.clone(), &file_path);
189 :
190 1 : assert_eq!(hunk.format(), lines.concat());
191 1 : assert!(!hunk.seen);
192 1 : assert!(!hunk.staged);
193 1 : assert!(hunk.staged_line_indices.is_empty());
194 1 : }
195 :
196 : #[test]
197 1 : fn seen_tracker_default_is_empty() {
198 1 : let file_path = PathBuf::from("src/default.rs");
199 1 : let hunk_id = HunkId::new(&file_path, 1, 1, &["+x\n".to_string()]);
200 1 : let mut tracker = SeenTracker::default();
201 1 : assert!(!tracker.is_seen(&hunk_id));
202 :
203 1 : tracker.mark_seen(&hunk_id);
204 1 : assert!(tracker.is_seen(&hunk_id));
205 1 : }
206 : }
|