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