ricecoder_undo_redo/
change.rs

1//! Change tracking and recording
2
3use crate::error::UndoRedoError;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use uuid::Uuid;
8
9/// Type of change made to a file
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ChangeType {
12    /// File was created
13    Create,
14    /// File was modified
15    Modify,
16    /// File was deleted
17    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/// Represents a single modification to a file or system state
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Change {
33    /// Unique identifier for this change
34    pub id: String,
35    /// When the change occurred
36    pub timestamp: DateTime<Utc>,
37    /// Path to the modified file
38    pub file_path: String,
39    /// State before the change
40    pub before: String,
41    /// State after the change
42    pub after: String,
43    /// Human-readable description of the change
44    pub description: String,
45    /// Type of change
46    pub change_type: ChangeType,
47}
48
49impl Change {
50    /// Create a new change with automatic UUID and timestamp
51    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        // Validate file path is not empty
64        if file_path.is_empty() {
65            return Err(UndoRedoError::validation_error("file_path cannot be empty"));
66        }
67
68        // Validate before/after state consistency
69        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    /// Validate the change for consistency
105    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
151/// Tracks changes to files and system state
152pub struct ChangeTracker {
153    pending_changes: Vec<Change>,
154}
155
156impl ChangeTracker {
157    /// Create a new change tracker
158    pub fn new() -> Self {
159        ChangeTracker {
160            pending_changes: Vec::new(),
161        }
162    }
163
164    /// Track a single change
165    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    /// Track multiple changes atomically
180    pub fn track_batch(&mut self, changes: Vec<Change>) -> Result<(), UndoRedoError> {
181        // Validate all changes first
182        for change in &changes {
183            change.validate()?;
184        }
185
186        // Add all changes if validation passes
187        self.pending_changes.extend(changes);
188        Ok(())
189    }
190
191    /// Get all pending changes
192    pub fn get_pending_changes(&self) -> Vec<Change> {
193        self.pending_changes.clone()
194    }
195
196    /// Clear pending changes
197    pub fn clear_pending(&mut self) {
198        self.pending_changes.clear();
199    }
200
201    /// Get the number of pending changes
202    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    /// Strategy for generating valid file paths
363    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    /// Strategy for generating valid content
369    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    /// Strategy for generating valid descriptions
375    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        /// **Feature: ricecoder-undo-redo, Property 2: History Completeness**
382        /// *For any* file modification operation, the change SHALL be recorded in the history
383        /// with complete before/after state.
384        /// **Validates: Requirements 1.1, 1.2, 1.3**
385        #[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            // Track a create change
394            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        /// **Feature: ricecoder-undo-redo, Property 2: History Completeness**
416        /// *For any* file modification operation, the change SHALL be recorded in the history
417        /// with complete before/after state.
418        /// **Validates: Requirements 1.1, 1.2, 1.3**
419        #[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            // Ensure before and after are different and non-empty for modify
427            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            // Track a modify change
434            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        /// **Feature: ricecoder-undo-redo, Property 2: History Completeness**
456        /// *For any* file modification operation, the change SHALL be recorded in the history
457        /// with complete before/after state.
458        /// **Validates: Requirements 1.1, 1.2, 1.3**
459        #[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            // Track a delete change
468            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        /// **Feature: ricecoder-undo-redo, Property 2: History Completeness**
490        /// *For any* sequence of file modifications, each modification SHALL be recorded
491        /// in the history with complete before/after state.
492        /// **Validates: Requirements 1.1, 1.2, 1.3**
493        #[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            // Create changes with different types
504            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                // Skip invalid combinations
512                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            // Track all changes
530            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            // Verify each change is recorded with complete state
541            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}