use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const HISTORY_LIMIT: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EditOp {
Snapshot,
Write,
ReplaceRange,
InsertAfter,
DeleteRange,
Patch,
Delete,
}
impl EditOp {
pub fn as_str(self) -> &'static str {
match self {
EditOp::Snapshot => "snapshot",
EditOp::Write => "write",
EditOp::ReplaceRange => "replace_range",
EditOp::InsertAfter => "insert_after",
EditOp::DeleteRange => "delete_range",
EditOp::Patch => "patch",
EditOp::Delete => "delete",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw {
"snapshot" => EditOp::Snapshot,
"write" => EditOp::Write,
"replace_range" => EditOp::ReplaceRange,
"insert_after" => EditOp::InsertAfter,
"delete_range" => EditOp::DeleteRange,
"patch" => EditOp::Patch,
"delete" => EditOp::Delete,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionEntry {
pub seq: u64,
pub agent_id: u64,
pub timestamp_ms: i64,
pub op: EditOp,
pub hash: u64,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeRecord {
pub path: String,
pub seq: u64,
pub agent_id: u64,
pub op: EditOp,
pub hash: u64,
pub size: u64,
pub timestamp_ms: i64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct VersionLog {
#[serde(default)]
pub current_seq: u64,
#[serde(default)]
pub history: HashMap<String, Vec<VersionEntry>>,
}
impl VersionLog {
pub fn new() -> Self {
Self::default()
}
pub fn record(
&mut self,
path: impl Into<String>,
agent_id: u64,
op: EditOp,
hash: u64,
size: u64,
timestamp_ms: i64,
) -> u64 {
self.current_seq = self.current_seq.saturating_add(1);
let entry = VersionEntry {
seq: self.current_seq,
agent_id,
timestamp_ms,
op,
hash,
size,
};
let path = path.into();
let list = self.history.entry(path).or_default();
list.push(entry);
if list.len() > HISTORY_LIMIT {
let drop = list.len() - HISTORY_LIMIT;
list.drain(0..drop);
}
self.current_seq
}
pub fn changes_since(&self, since: u64, limit: Option<usize>) -> Vec<ChangeRecord> {
let mut out: Vec<ChangeRecord> = Vec::new();
for (path, entries) in &self.history {
for entry in entries {
if entry.seq > since {
out.push(ChangeRecord {
path: path.clone(),
seq: entry.seq,
agent_id: entry.agent_id,
op: entry.op,
hash: entry.hash,
size: entry.size,
timestamp_ms: entry.timestamp_ms,
});
}
}
}
out.sort_by_key(|r| r.seq);
if let Some(limit) = limit {
if out.len() > limit {
let start = out.len() - limit;
out = out.split_off(start);
}
}
out
}
pub fn last_entry(&self, path: &str) -> Option<&VersionEntry> {
self.history.get(path).and_then(|v| v.last())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_assigns_monotonic_seqs() {
let mut log = VersionLog::new();
let s1 = log.record("a.rs", 1, EditOp::Write, 10, 5, 100);
let s2 = log.record("b.rs", 1, EditOp::Write, 11, 5, 110);
let s3 = log.record("a.rs", 2, EditOp::Write, 12, 5, 120);
assert_eq!((s1, s2, s3), (1, 2, 3));
assert_eq!(log.current_seq, 3);
}
#[test]
fn changes_since_returns_sorted_records() {
let mut log = VersionLog::new();
log.record("a.rs", 1, EditOp::Write, 1, 1, 100);
log.record("b.rs", 2, EditOp::Write, 2, 2, 110);
log.record("a.rs", 3, EditOp::Patch, 3, 3, 120);
let changes = log.changes_since(1, None);
let seqs: Vec<u64> = changes.iter().map(|c| c.seq).collect();
assert_eq!(seqs, vec![2, 3]);
}
#[test]
fn changes_since_respects_limit_and_keeps_most_recent() {
let mut log = VersionLog::new();
for i in 1..=5 {
log.record("a.rs", 1, EditOp::Write, i, i, i as i64);
}
let limited = log.changes_since(0, Some(2));
let seqs: Vec<u64> = limited.iter().map(|c| c.seq).collect();
assert_eq!(seqs, vec![4, 5]);
}
#[test]
fn history_caps_at_history_limit() {
let mut log = VersionLog::new();
for i in 0..(HISTORY_LIMIT + 50) {
log.record("a.rs", 1, EditOp::Write, i as u64, 0, 0);
}
let entries = log.history.get("a.rs").unwrap();
assert_eq!(entries.len(), HISTORY_LIMIT);
assert!(entries.first().unwrap().seq > 1);
}
#[test]
fn edit_op_round_trips_through_str_form() {
for op in [
EditOp::Snapshot,
EditOp::Write,
EditOp::ReplaceRange,
EditOp::InsertAfter,
EditOp::DeleteRange,
EditOp::Patch,
EditOp::Delete,
] {
assert_eq!(EditOp::parse(op.as_str()), Some(op));
}
assert_eq!(EditOp::parse("unknown"), None);
}
}