1use crate::error::UndoRedoError;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ChangeType {
12 Create,
14 Modify,
16 Delete,
18}
19
20impl fmt::Display for ChangeType {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 ChangeType::Create => write!(f, "Create"),
24 ChangeType::Modify => write!(f, "Modify"),
25 ChangeType::Delete => write!(f, "Delete"),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Change {
33 pub id: String,
35 pub timestamp: DateTime<Utc>,
37 pub file_path: String,
39 pub before: String,
41 pub after: String,
43 pub description: String,
45 pub change_type: ChangeType,
47}
48
49impl Change {
50 pub fn new(
52 file_path: impl Into<String>,
53 before: impl Into<String>,
54 after: impl Into<String>,
55 description: impl Into<String>,
56 change_type: ChangeType,
57 ) -> Result<Self, UndoRedoError> {
58 let file_path = file_path.into();
59 let before = before.into();
60 let after = after.into();
61 let description = description.into();
62
63 if file_path.is_empty() {
65 return Err(UndoRedoError::validation_error("file_path cannot be empty"));
66 }
67
68 match change_type {
70 ChangeType::Create => {
71 if !before.is_empty() {
72 return Err(UndoRedoError::validation_error(
73 "Create change must have empty before state",
74 ));
75 }
76 }
77 ChangeType::Delete => {
78 if !after.is_empty() {
79 return Err(UndoRedoError::validation_error(
80 "Delete change must have empty after state",
81 ));
82 }
83 }
84 ChangeType::Modify => {
85 if before.is_empty() || after.is_empty() {
86 return Err(UndoRedoError::validation_error(
87 "Modify change must have non-empty before and after states",
88 ));
89 }
90 }
91 }
92
93 Ok(Change {
94 id: Uuid::new_v4().to_string(),
95 timestamp: Utc::now(),
96 file_path,
97 before,
98 after,
99 description,
100 change_type,
101 })
102 }
103
104 pub fn validate(&self) -> Result<(), UndoRedoError> {
106 if self.file_path.is_empty() {
107 return Err(UndoRedoError::validation_error("file_path cannot be empty"));
108 }
109
110 match self.change_type {
111 ChangeType::Create => {
112 if !self.before.is_empty() {
113 return Err(UndoRedoError::validation_error(
114 "Create change must have empty before state",
115 ));
116 }
117 }
118 ChangeType::Delete => {
119 if !self.after.is_empty() {
120 return Err(UndoRedoError::validation_error(
121 "Delete change must have empty after state",
122 ));
123 }
124 }
125 ChangeType::Modify => {
126 if self.before.is_empty() || self.after.is_empty() {
127 return Err(UndoRedoError::validation_error(
128 "Modify change must have non-empty before and after states",
129 ));
130 }
131 }
132 }
133
134 Ok(())
135 }
136}
137
138impl fmt::Display for Change {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(
141 f,
142 "[{}] {} - {} ({})",
143 self.timestamp.format("%Y-%m-%d %H:%M:%S"),
144 self.change_type,
145 self.file_path,
146 self.description
147 )
148 }
149}
150
151pub struct ChangeTracker {
153 pending_changes: Vec<Change>,
154}
155
156impl ChangeTracker {
157 pub fn new() -> Self {
159 ChangeTracker {
160 pending_changes: Vec::new(),
161 }
162 }
163
164 pub fn track_change(
166 &mut self,
167 file_path: impl Into<String>,
168 before: impl Into<String>,
169 after: impl Into<String>,
170 description: impl Into<String>,
171 change_type: ChangeType,
172 ) -> Result<String, UndoRedoError> {
173 let change = Change::new(file_path, before, after, description, change_type)?;
174 let id = change.id.clone();
175 self.pending_changes.push(change);
176 Ok(id)
177 }
178
179 pub fn track_batch(&mut self, changes: Vec<Change>) -> Result<(), UndoRedoError> {
181 for change in &changes {
183 change.validate()?;
184 }
185
186 self.pending_changes.extend(changes);
188 Ok(())
189 }
190
191 pub fn get_pending_changes(&self) -> Vec<Change> {
193 self.pending_changes.clone()
194 }
195
196 pub fn clear_pending(&mut self) {
198 self.pending_changes.clear();
199 }
200
201 pub fn pending_count(&self) -> usize {
203 self.pending_changes.len()
204 }
205}
206
207impl Default for ChangeTracker {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_change_create_valid() {
219 let change = Change::new(
220 "test.txt",
221 "",
222 "content",
223 "Create test file",
224 ChangeType::Create,
225 );
226 assert!(change.is_ok());
227 let change = change.unwrap();
228 assert_eq!(change.file_path, "test.txt");
229 assert_eq!(change.before, "");
230 assert_eq!(change.after, "content");
231 assert_eq!(change.change_type, ChangeType::Create);
232 }
233
234 #[test]
235 fn test_change_modify_valid() {
236 let change = Change::new(
237 "test.txt",
238 "old content",
239 "new content",
240 "Modify test file",
241 ChangeType::Modify,
242 );
243 assert!(change.is_ok());
244 }
245
246 #[test]
247 fn test_change_delete_valid() {
248 let change = Change::new(
249 "test.txt",
250 "content",
251 "",
252 "Delete test file",
253 ChangeType::Delete,
254 );
255 assert!(change.is_ok());
256 }
257
258 #[test]
259 fn test_change_empty_file_path() {
260 let change = Change::new("", "before", "after", "desc", ChangeType::Modify);
261 assert!(change.is_err());
262 }
263
264 #[test]
265 fn test_change_create_with_before_state() {
266 let change = Change::new(
267 "test.txt",
268 "before",
269 "after",
270 "desc",
271 ChangeType::Create,
272 );
273 assert!(change.is_err());
274 }
275
276 #[test]
277 fn test_change_delete_with_after_state() {
278 let change = Change::new(
279 "test.txt",
280 "before",
281 "after",
282 "desc",
283 ChangeType::Delete,
284 );
285 assert!(change.is_err());
286 }
287
288 #[test]
289 fn test_change_tracker_track_single() {
290 let mut tracker = ChangeTracker::new();
291 let result = tracker.track_change(
292 "test.txt",
293 "before",
294 "after",
295 "Modify",
296 ChangeType::Modify,
297 );
298 assert!(result.is_ok());
299 assert_eq!(tracker.pending_count(), 1);
300 }
301
302 #[test]
303 fn test_change_tracker_track_batch() {
304 let mut tracker = ChangeTracker::new();
305 let changes = vec![
306 Change::new("file1.txt", "", "content1", "Create 1", ChangeType::Create).unwrap(),
307 Change::new("file2.txt", "", "content2", "Create 2", ChangeType::Create).unwrap(),
308 ];
309 let result = tracker.track_batch(changes);
310 assert!(result.is_ok());
311 assert_eq!(tracker.pending_count(), 2);
312 }
313
314 #[test]
315 fn test_change_tracker_clear_pending() {
316 let mut tracker = ChangeTracker::new();
317 tracker
318 .track_change("test.txt", "before", "after", "Modify", ChangeType::Modify)
319 .unwrap();
320 assert_eq!(tracker.pending_count(), 1);
321 tracker.clear_pending();
322 assert_eq!(tracker.pending_count(), 0);
323 }
324
325 #[test]
326 fn test_change_display() {
327 let change = Change::new(
328 "test.txt",
329 "before",
330 "after",
331 "Modify test",
332 ChangeType::Modify,
333 )
334 .unwrap();
335 let display = format!("{}", change);
336 assert!(display.contains("Modify"));
337 assert!(display.contains("test.txt"));
338 }
339
340 #[test]
341 fn test_change_serialization() {
342 let change = Change::new(
343 "test.txt",
344 "before",
345 "after",
346 "Modify",
347 ChangeType::Modify,
348 )
349 .unwrap();
350 let json = serde_json::to_string(&change).unwrap();
351 let deserialized: Change = serde_json::from_str(&json).unwrap();
352 assert_eq!(change.id, deserialized.id);
353 assert_eq!(change.file_path, deserialized.file_path);
354 }
355}
356
357#[cfg(test)]
358mod property_tests {
359 use super::*;
360 use proptest::prelude::*;
361
362 fn file_path_strategy() -> impl Strategy<Value = String> {
364 r"[a-zA-Z0-9_\-./]{1,50}\.rs"
365 .prop_map(|s| s.to_string())
366 }
367
368 fn content_strategy() -> impl Strategy<Value = String> {
370 r"[a-zA-Z0-9\s\n\t]{0,200}"
371 .prop_map(|s| s.to_string())
372 }
373
374 fn description_strategy() -> impl Strategy<Value = String> {
376 r"[a-zA-Z0-9\s]{1,50}"
377 .prop_map(|s| s.to_string())
378 }
379
380 proptest! {
381 #[test]
386 fn prop_history_completeness_create(
387 file_path in file_path_strategy(),
388 content in content_strategy(),
389 description in description_strategy(),
390 ) {
391 let mut tracker = ChangeTracker::new();
392
393 let result = tracker.track_change(
395 &file_path,
396 "",
397 &content,
398 &description,
399 ChangeType::Create,
400 );
401
402 prop_assert!(result.is_ok(), "Change tracking should succeed");
403
404 let pending = tracker.get_pending_changes();
405 prop_assert_eq!(pending.len(), 1, "Should have exactly one pending change");
406
407 let change = &pending[0];
408 prop_assert_eq!(&change.file_path, &file_path, "File path should match");
409 prop_assert_eq!(&change.before, "", "Before state should be empty for Create");
410 prop_assert_eq!(&change.after, &content, "After state should match content");
411 prop_assert_eq!(&change.description, &description, "Description should match");
412 prop_assert_eq!(change.change_type, ChangeType::Create, "Change type should be Create");
413 }
414
415 #[test]
420 fn prop_history_completeness_modify(
421 file_path in file_path_strategy(),
422 before_content in content_strategy(),
423 after_content in content_strategy(),
424 description in description_strategy(),
425 ) {
426 prop_assume!(before_content != after_content);
428 prop_assume!(!before_content.is_empty());
429 prop_assume!(!after_content.is_empty());
430
431 let mut tracker = ChangeTracker::new();
432
433 let result = tracker.track_change(
435 &file_path,
436 &before_content,
437 &after_content,
438 &description,
439 ChangeType::Modify,
440 );
441
442 prop_assert!(result.is_ok(), "Change tracking should succeed");
443
444 let pending = tracker.get_pending_changes();
445 prop_assert_eq!(pending.len(), 1, "Should have exactly one pending change");
446
447 let change = &pending[0];
448 prop_assert_eq!(&change.file_path, &file_path, "File path should match");
449 prop_assert_eq!(&change.before, &before_content, "Before state should match");
450 prop_assert_eq!(&change.after, &after_content, "After state should match");
451 prop_assert_eq!(&change.description, &description, "Description should match");
452 prop_assert_eq!(change.change_type, ChangeType::Modify, "Change type should be Modify");
453 }
454
455 #[test]
460 fn prop_history_completeness_delete(
461 file_path in file_path_strategy(),
462 content in content_strategy(),
463 description in description_strategy(),
464 ) {
465 let mut tracker = ChangeTracker::new();
466
467 let result = tracker.track_change(
469 &file_path,
470 &content,
471 "",
472 &description,
473 ChangeType::Delete,
474 );
475
476 prop_assert!(result.is_ok(), "Change tracking should succeed");
477
478 let pending = tracker.get_pending_changes();
479 prop_assert_eq!(pending.len(), 1, "Should have exactly one pending change");
480
481 let change = &pending[0];
482 prop_assert_eq!(&change.file_path, &file_path, "File path should match");
483 prop_assert_eq!(&change.before, &content, "Before state should match content");
484 prop_assert_eq!(&change.after, "", "After state should be empty for Delete");
485 prop_assert_eq!(&change.description, &description, "Description should match");
486 prop_assert_eq!(change.change_type, ChangeType::Delete, "Change type should be Delete");
487 }
488
489 #[test]
494 fn prop_history_completeness_batch(
495 changes_data in prop::collection::vec(
496 (file_path_strategy(), content_strategy(), content_strategy()),
497 1..10
498 ),
499 ) {
500 let mut tracker = ChangeTracker::new();
501 let mut expected_changes = Vec::new();
502
503 for (idx, (file_path, before, after)) in changes_data.iter().enumerate() {
505 let change_type = match idx % 3 {
506 0 => ChangeType::Create,
507 1 => ChangeType::Modify,
508 _ => ChangeType::Delete,
509 };
510
511 if (change_type == ChangeType::Create && !before.is_empty()) ||
513 (change_type == ChangeType::Delete && !after.is_empty()) ||
514 (change_type == ChangeType::Modify && (before.is_empty() || after.is_empty())) {
515 continue;
516 }
517
518 if let Ok(change) = Change::new(
519 file_path.clone(),
520 before.clone(),
521 after.clone(),
522 format!("Change {}", idx),
523 change_type,
524 ) {
525 expected_changes.push(change);
526 }
527 }
528
529 let result = tracker.track_batch(expected_changes.clone());
531 prop_assert!(result.is_ok(), "Batch tracking should succeed");
532
533 let pending = tracker.get_pending_changes();
534 prop_assert_eq!(
535 pending.len(),
536 expected_changes.len(),
537 "All changes should be recorded"
538 );
539
540 for (recorded, expected) in pending.iter().zip(expected_changes.iter()) {
542 prop_assert_eq!(&recorded.file_path, &expected.file_path, "File path should match");
543 prop_assert_eq!(&recorded.before, &expected.before, "Before state should match");
544 prop_assert_eq!(&recorded.after, &expected.after, "After state should match");
545 prop_assert_eq!(recorded.change_type, expected.change_type, "Change type should match");
546 }
547 }
548 }
549}