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