Skip to main content

ripsed_core/
undo.rs

1use serde::{Deserialize, Serialize};
2
3/// An entry in the undo log, storing enough information to reverse an operation.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct UndoEntry {
6    /// The full original text before the operation.
7    pub original_text: String,
8}
9
10/// A record in the persistent undo log file (.ripsed/undo.jsonl).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct UndoRecord {
13    pub timestamp: String,
14    pub file_path: String,
15    pub entry: UndoEntry,
16}
17
18/// Manages the undo log.
19pub struct UndoLog {
20    records: Vec<UndoRecord>,
21    max_entries: usize,
22}
23
24impl UndoLog {
25    pub fn new(max_entries: usize) -> Self {
26        Self {
27            records: Vec::new(),
28            max_entries,
29        }
30    }
31
32    /// Load undo log from JSONL content.
33    pub fn from_jsonl(content: &str, max_entries: usize) -> Self {
34        let records: Vec<UndoRecord> = content
35            .lines()
36            .filter(|line| !line.trim().is_empty())
37            .filter_map(|line| serde_json::from_str(line).ok())
38            .collect();
39
40        Self {
41            records,
42            max_entries,
43        }
44    }
45
46    /// Serialize the log to JSONL format.
47    pub fn to_jsonl(&self) -> String {
48        self.records
49            .iter()
50            .filter_map(|r| serde_json::to_string(r).ok())
51            .collect::<Vec<_>>()
52            .join("\n")
53            + if self.records.is_empty() { "" } else { "\n" }
54    }
55
56    /// Append a new undo record.
57    pub fn push(&mut self, record: UndoRecord) {
58        self.records.push(record);
59        self.prune();
60    }
61
62    /// Remove the last N records and return them (for undo).
63    pub fn pop(&mut self, count: usize) -> Vec<UndoRecord> {
64        let drain_start = self.records.len().saturating_sub(count);
65        self.records.drain(drain_start..).rev().collect()
66    }
67
68    /// Number of entries in the log.
69    pub fn len(&self) -> usize {
70        self.records.len()
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.records.is_empty()
75    }
76
77    /// Get recent entries for display.
78    pub fn recent(&self, count: usize) -> &[UndoRecord] {
79        let start = self.records.len().saturating_sub(count);
80        &self.records[start..]
81    }
82
83    fn prune(&mut self) {
84        if self.records.len() > self.max_entries {
85            let excess = self.records.len() - self.max_entries;
86            self.records.drain(..excess);
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_push_and_pop() {
97        let mut log = UndoLog::new(100);
98        log.push(UndoRecord {
99            timestamp: "2026-01-01T00:00:00Z".to_string(),
100            file_path: "test.txt".to_string(),
101            entry: UndoEntry {
102                original_text: "hello".to_string(),
103            },
104        });
105        assert_eq!(log.len(), 1);
106        let popped = log.pop(1);
107        assert_eq!(popped.len(), 1);
108        assert_eq!(popped[0].file_path, "test.txt");
109        assert!(log.is_empty());
110    }
111
112    #[test]
113    fn test_pruning() {
114        let mut log = UndoLog::new(2);
115        for i in 0..5 {
116            log.push(UndoRecord {
117                timestamp: format!("2026-01-0{i}T00:00:00Z"),
118                file_path: format!("file{i}.txt"),
119                entry: UndoEntry {
120                    original_text: format!("content{i}"),
121                },
122            });
123        }
124        assert_eq!(log.len(), 2);
125    }
126
127    #[test]
128    fn test_jsonl_roundtrip() {
129        let mut log = UndoLog::new(100);
130        log.push(UndoRecord {
131            timestamp: "2026-01-01T00:00:00Z".to_string(),
132            file_path: "test.txt".to_string(),
133            entry: UndoEntry {
134                original_text: "original".to_string(),
135            },
136        });
137        let jsonl = log.to_jsonl();
138        let loaded = UndoLog::from_jsonl(&jsonl, 100);
139        assert_eq!(loaded.len(), 1);
140    }
141}