1use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17pub const HISTORY_LIMIT: usize = 200;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum EditOp {
26 Snapshot,
28 Write,
30 ReplaceRange,
32 InsertAfter,
34 DeleteRange,
36 Patch,
38 Delete,
40}
41
42impl EditOp {
43 pub fn as_str(self) -> &'static str {
46 match self {
47 EditOp::Snapshot => "snapshot",
48 EditOp::Write => "write",
49 EditOp::ReplaceRange => "replace_range",
50 EditOp::InsertAfter => "insert_after",
51 EditOp::DeleteRange => "delete_range",
52 EditOp::Patch => "patch",
53 EditOp::Delete => "delete",
54 }
55 }
56
57 pub fn parse(raw: &str) -> Option<Self> {
60 Some(match raw {
61 "snapshot" => EditOp::Snapshot,
62 "write" => EditOp::Write,
63 "replace_range" => EditOp::ReplaceRange,
64 "insert_after" => EditOp::InsertAfter,
65 "delete_range" => EditOp::DeleteRange,
66 "patch" => EditOp::Patch,
67 "delete" => EditOp::Delete,
68 _ => return None,
69 })
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub struct VersionEntry {
76 pub seq: u64,
78 pub agent_id: u64,
80 pub timestamp_ms: i64,
82 pub op: EditOp,
84 pub hash: u64,
86 pub size: u64,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ChangeRecord {
93 pub path: String,
95 pub seq: u64,
97 pub agent_id: u64,
99 pub op: EditOp,
101 pub hash: u64,
103 pub size: u64,
105 pub timestamp_ms: i64,
107}
108
109#[derive(Debug, Default, Clone, Serialize, Deserialize)]
113pub struct VersionLog {
114 #[serde(default)]
116 pub current_seq: u64,
117 #[serde(default)]
119 pub history: HashMap<String, Vec<VersionEntry>>,
120}
121
122impl VersionLog {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn record(
131 &mut self,
132 path: impl Into<String>,
133 agent_id: u64,
134 op: EditOp,
135 hash: u64,
136 size: u64,
137 timestamp_ms: i64,
138 ) -> u64 {
139 self.current_seq = self.current_seq.saturating_add(1);
140 let entry = VersionEntry {
141 seq: self.current_seq,
142 agent_id,
143 timestamp_ms,
144 op,
145 hash,
146 size,
147 };
148 let path = path.into();
149 let list = self.history.entry(path).or_default();
150 list.push(entry);
151 if list.len() > HISTORY_LIMIT {
152 let drop = list.len() - HISTORY_LIMIT;
153 list.drain(0..drop);
154 }
155 self.current_seq
156 }
157
158 pub fn changes_since(&self, since: u64, limit: Option<usize>) -> Vec<ChangeRecord> {
161 let mut out: Vec<ChangeRecord> = Vec::new();
162 for (path, entries) in &self.history {
163 for entry in entries {
164 if entry.seq > since {
165 out.push(ChangeRecord {
166 path: path.clone(),
167 seq: entry.seq,
168 agent_id: entry.agent_id,
169 op: entry.op,
170 hash: entry.hash,
171 size: entry.size,
172 timestamp_ms: entry.timestamp_ms,
173 });
174 }
175 }
176 }
177 out.sort_by_key(|r| r.seq);
178 if let Some(limit) = limit {
179 if out.len() > limit {
180 let start = out.len() - limit;
181 out = out.split_off(start);
182 }
183 }
184 out
185 }
186
187 pub fn last_entry(&self, path: &str) -> Option<&VersionEntry> {
189 self.history.get(path).and_then(|v| v.last())
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn record_assigns_monotonic_seqs() {
199 let mut log = VersionLog::new();
200 let s1 = log.record("a.rs", 1, EditOp::Write, 10, 5, 100);
201 let s2 = log.record("b.rs", 1, EditOp::Write, 11, 5, 110);
202 let s3 = log.record("a.rs", 2, EditOp::Write, 12, 5, 120);
203 assert_eq!((s1, s2, s3), (1, 2, 3));
204 assert_eq!(log.current_seq, 3);
205 }
206
207 #[test]
208 fn changes_since_returns_sorted_records() {
209 let mut log = VersionLog::new();
210 log.record("a.rs", 1, EditOp::Write, 1, 1, 100);
211 log.record("b.rs", 2, EditOp::Write, 2, 2, 110);
212 log.record("a.rs", 3, EditOp::Patch, 3, 3, 120);
213 let changes = log.changes_since(1, None);
214 let seqs: Vec<u64> = changes.iter().map(|c| c.seq).collect();
215 assert_eq!(seqs, vec![2, 3]);
216 }
217
218 #[test]
219 fn changes_since_respects_limit_and_keeps_most_recent() {
220 let mut log = VersionLog::new();
221 for i in 1..=5 {
222 log.record("a.rs", 1, EditOp::Write, i, i, i as i64);
223 }
224 let limited = log.changes_since(0, Some(2));
225 let seqs: Vec<u64> = limited.iter().map(|c| c.seq).collect();
226 assert_eq!(seqs, vec![4, 5]);
227 }
228
229 #[test]
230 fn history_caps_at_history_limit() {
231 let mut log = VersionLog::new();
232 for i in 0..(HISTORY_LIMIT + 50) {
233 log.record("a.rs", 1, EditOp::Write, i as u64, 0, 0);
234 }
235 let entries = log.history.get("a.rs").unwrap();
236 assert_eq!(entries.len(), HISTORY_LIMIT);
237 assert!(entries.first().unwrap().seq > 1);
239 }
240
241 #[test]
242 fn edit_op_round_trips_through_str_form() {
243 for op in [
244 EditOp::Snapshot,
245 EditOp::Write,
246 EditOp::ReplaceRange,
247 EditOp::InsertAfter,
248 EditOp::DeleteRange,
249 EditOp::Patch,
250 EditOp::Delete,
251 ] {
252 assert_eq!(EditOp::parse(op.as_str()), Some(op));
253 }
254 assert_eq!(EditOp::parse("unknown"), None);
255 }
256}