Skip to main content

harn_hostlib/code_index/
versions.rs

1//! Append-only version log of file mutations.
2//!
3//! Mirrors the Swift `VersionLog` struct in `Sources/BurinCodeIndex/`.
4//! Every successful write through the host's `version_record` op lands
5//! here with a monotonic sequence number. Agents call `changes_since(seq)`
6//! to catch up between turns without re-reading every file, and the seq
7//! numbers let higher-level tooling spot "agent fighting itself" loops
8//! before they cause damage.
9//!
10//! Per-file history is capped at [`HISTORY_LIMIT`] entries so an agent
11//! that thrashes one path doesn't blow up the log indefinitely. The seq
12//! counter itself is global.
13
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17/// Maximum number of entries kept per path. Older entries roll off the
18/// front in FIFO order — matches the Swift constant.
19pub const HISTORY_LIMIT: usize = 200;
20
21/// Edit-classification for one record. The string forms ride out to Harn
22/// scripts and the cross-repo schema so callers can switch on them.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum EditOp {
26    /// Mark a snapshot/checkpoint, not a content change.
27    Snapshot,
28    /// Whole-file write.
29    Write,
30    /// Targeted line-range replacement.
31    ReplaceRange,
32    /// Insertion after a specified line.
33    InsertAfter,
34    /// Targeted line-range deletion.
35    DeleteRange,
36    /// Patch/diff application.
37    Patch,
38    /// File deletion.
39    Delete,
40}
41
42impl EditOp {
43    /// String form used by the Harn host bridge. Mirrors the Swift
44    /// `EditOp.rawValue` so cross-repo callers stay aligned.
45    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    /// Parse from the string form; returns `None` for unknown variants so
58    /// the caller can decide whether to default to `Write` or to error.
59    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/// One entry in the per-file history.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub struct VersionEntry {
76    /// Globally monotonic sequence number.
77    pub seq: u64,
78    /// Agent that recorded the edit.
79    pub agent_id: u64,
80    /// Wall-clock ms since the Unix epoch.
81    pub timestamp_ms: i64,
82    /// Edit classification.
83    pub op: EditOp,
84    /// Post-edit content hash (or `0` if not applicable, e.g. `Delete`).
85    pub hash: u64,
86    /// Post-edit byte size.
87    pub size: u64,
88}
89
90/// Public denormalised form returned by `changes_since`.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ChangeRecord {
93    /// Workspace-relative path the edit was attributed to.
94    pub path: String,
95    /// Globally monotonic sequence number.
96    pub seq: u64,
97    /// Agent id that recorded the edit.
98    pub agent_id: u64,
99    /// Edit classification.
100    pub op: EditOp,
101    /// Post-edit hash.
102    pub hash: u64,
103    /// Post-edit size.
104    pub size: u64,
105    /// Wall-clock ms since the Unix epoch.
106    pub timestamp_ms: i64,
107}
108
109/// Append-only log keyed by path. Both forward query patterns —
110/// "everything since X" and "the latest entry for this path" — are
111/// served from the same map.
112#[derive(Debug, Default, Clone, Serialize, Deserialize)]
113pub struct VersionLog {
114    /// Latest assigned sequence number. Monotonically increases.
115    #[serde(default)]
116    pub current_seq: u64,
117    /// Per-path history, newest-last; capped at [`HISTORY_LIMIT`] entries.
118    #[serde(default)]
119    pub history: HashMap<String, Vec<VersionEntry>>,
120}
121
122impl VersionLog {
123    /// Construct an empty log.
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Record one edit. Returns the assigned seq, which is what the host
129    /// builtin echoes back to the caller.
130    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    /// Every change record with `seq > since`, ordered by seq ascending.
159    /// `limit` (when present) keeps the *most recent* `limit` entries.
160    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    /// Latest entry recorded for `path`, or `None` if untracked.
188    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        // Front of the list rolled off — the very first record is gone.
238        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}