Skip to main content

zenith_session/
revspec.rs

1//! Revision-spec resolution: map a revspec string to a session record id.
2//!
3//! Supported forms (resolved over the Tier-1 journal DAG):
4//! - `@head` / `HEAD` / `@`            → the current HEAD record
5//! - `@head~N` / `HEAD~N` (N >= 0)     → walk N parent links back from HEAD
6//! - `@head^` / `HEAD^`                → parent of HEAD (same as `~1`)
7//! - `<seq>` (a bare non-negative int) → the record with that `seq`
8//! - `@time:<unix_ms>`                 → the most recent record at or before that time
9//! - `@latest:<label>`                 → the highest-seq record whose `op_kind` OR `label` equals `<label>`
10//! - `<id>` or a unique `<id-prefix>`  → the record whose id equals / uniquely begins with the text
11//!
12//! Resolution is deterministic and never panics; ambiguity or no-match is an error.
13
14use crate::adapter::Fs;
15use crate::error::SessionError;
16use crate::layout::StorePaths;
17use crate::manifest::{HistoryRecord, read_records};
18use crate::session::{find_record, journal_path, load_state};
19
20// ── Private helpers ────────────────────────────────────────────────────────────
21
22/// Walk `n` parent links back from `start`, returning the resulting record id.
23/// `n == 0` returns `start` unchanged.
24fn walk_parents(
25    records: &[HistoryRecord],
26    start: &str,
27    n: usize,
28    spec: &str,
29) -> Result<String, SessionError> {
30    let mut cur = start.to_owned();
31    for _ in 0..n {
32        let rec = find_record(records, &cur)
33            .ok_or_else(|| SessionError::new(format!("unknown record {cur}")))?;
34        cur = rec
35            .parent
36            .clone()
37            .ok_or_else(|| SessionError::new(format!("revspec {spec} goes past the root")))?;
38    }
39    Ok(cur)
40}
41
42/// Among records with `timestamp_ms <= target`, pick the one with the largest
43/// `timestamp_ms`; tie-break by largest `seq`. Returns `None` if no candidates.
44fn resolve_time(records: &[HistoryRecord], target: u128) -> Option<String> {
45    records
46        .iter()
47        .filter_map(|r| {
48            r.timestamp_ms
49                .filter(|&ts| ts <= target)
50                .map(|ts| (ts, r.seq, &r.id))
51        })
52        .max_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)))
53        .map(|(_, _, id)| id.clone())
54}
55
56/// Among records whose `op_kind` or `label` equals `label`, pick the one with
57/// the highest `seq`. Returns `None` if no candidates.
58fn resolve_latest(records: &[HistoryRecord], label: &str) -> Option<String> {
59    records
60        .iter()
61        .filter(|r| r.op_kind.as_deref() == Some(label) || r.label.as_deref() == Some(label))
62        .max_by_key(|r| r.seq)
63        .map(|r| r.id.clone())
64}
65
66/// Resolve an id or id-prefix: exact match wins; otherwise exactly-one prefix
67/// match succeeds; zero or multiple prefix matches are errors.
68fn resolve_id_or_prefix(records: &[HistoryRecord], s: &str) -> Result<String, SessionError> {
69    // Exact match short-circuits immediately.
70    if let Some(rec) = find_record(records, s) {
71        return Ok(rec.id.clone());
72    }
73    // Prefix scan.
74    let matches: Vec<&HistoryRecord> = records.iter().filter(|r| r.id.starts_with(s)).collect();
75    match matches.len() {
76        0 => Err(SessionError::new(format!("no record matching {s}"))),
77        1 => Ok(matches[0].id.clone()),
78        n => Err(SessionError::new(format!(
79            "ambiguous revspec {s} matches {n} records"
80        ))),
81    }
82}
83
84// ── Public pure resolver ───────────────────────────────────────────────────────
85
86/// Resolve `spec` to a record id, given the full record list and the current
87/// HEAD (`None` if the session is empty). Returns an error if the spec is
88/// malformed, ambiguous, or matches no record.
89pub fn resolve_revspec(
90    records: &[HistoryRecord],
91    head: Option<&str>,
92    spec: &str,
93) -> Result<String, SessionError> {
94    let s = spec.trim();
95
96    // 1. Empty spec.
97    if s.is_empty() {
98        return Err(SessionError::new("empty revspec"));
99    }
100
101    // 2. Bare HEAD aliases.
102    if s == "@head" || s == "HEAD" || s == "@" {
103        return head
104            .map(str::to_owned)
105            .ok_or_else(|| SessionError::new("no HEAD in session"));
106    }
107
108    // 3. HEAD-relative parent walk.
109    if let Some(rest) = s.strip_prefix("@head").or_else(|| s.strip_prefix("HEAD")) {
110        // `rest` is the suffix after "@head" or "HEAD".
111        let head_id = head.ok_or_else(|| SessionError::new("no HEAD in session"))?;
112        if rest == "^" {
113            return walk_parents(records, head_id, 1, s);
114        }
115        if let Some(n_str) = rest.strip_prefix('~') {
116            let n = n_str
117                .parse::<usize>()
118                .map_err(|_| SessionError::new(format!("unrecognized HEAD revspec: {s}")))?;
119            return walk_parents(records, head_id, n, s);
120        }
121        return Err(SessionError::new(format!("unrecognized HEAD revspec: {s}")));
122    }
123
124    // 4. @time:<unix_ms>
125    if let Some(ts_str) = s.strip_prefix("@time:") {
126        let target = ts_str
127            .parse::<u128>()
128            .map_err(|_| SessionError::new(format!("invalid @time timestamp: {ts_str}")))?;
129        return resolve_time(records, target)
130            .ok_or_else(|| SessionError::new(format!("no record at or before time {target}")));
131    }
132
133    // 5. @latest:<label>
134    if let Some(label) = s.strip_prefix("@latest:") {
135        return resolve_latest(records, label)
136            .ok_or_else(|| SessionError::new(format!("no record matching @latest:{label}")));
137    }
138
139    // 6. Bare non-negative integer → seq lookup.
140    if let Ok(n) = s.parse::<u64>() {
141        return records
142            .iter()
143            .find(|r| r.seq == n)
144            .map(|r| r.id.clone())
145            .ok_or_else(|| SessionError::new(format!("no record with seq {n}")));
146    }
147
148    // 7. Id or id-prefix.
149    resolve_id_or_prefix(records, s)
150}
151
152// ── Public fs convenience ──────────────────────────────────────────────────────
153
154/// Load the session for `doc_id` and resolve `spec` to a record id.
155pub fn resolve_revspec_for(
156    fs: &impl Fs,
157    paths: &StorePaths,
158    doc_id: &str,
159    spec: &str,
160) -> Result<String, SessionError> {
161    let state = load_state(fs, paths, doc_id)?;
162    let records = read_records(fs, &journal_path(paths, doc_id))?;
163    resolve_revspec(&records, state.head.as_deref(), spec)
164}
165
166// ── Tests ──────────────────────────────────────────────────────────────────────
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::adapter::{FakeClock, FakeRng, MemFs};
172    use crate::layout::StorePaths;
173    use crate::session::record_state;
174
175    // ── Test fixture ──────────────────────────────────────────────────────────
176
177    /// Build a 4-record chain:
178    /// r0(seq0,parent None, ts 100, op "edit")
179    ///   ← r1(seq1,parent r0, ts 200, op "external")
180    ///     ← r2(seq2,parent r1, ts 300, op "edit", label Some("v1.0"))
181    ///       ← r3(seq3,parent r2, ts 400, op "edit")
182    /// head = "r3"
183    fn make_chain() -> Vec<HistoryRecord> {
184        let mut r0 = HistoryRecord::new("r0", 0, None, "snap0");
185        r0.timestamp_ms = Some(100);
186        r0.op_kind = Some("edit".to_owned());
187
188        let mut r1 = HistoryRecord::new("r1", 1, Some("r0".to_owned()), "snap1");
189        r1.timestamp_ms = Some(200);
190        r1.op_kind = Some("external".to_owned());
191
192        let mut r2 = HistoryRecord::new("r2", 2, Some("r1".to_owned()), "snap2");
193        r2.timestamp_ms = Some(300);
194        r2.op_kind = Some("edit".to_owned());
195        r2.label = Some("v1.0".to_owned());
196
197        let mut r3 = HistoryRecord::new("r3", 3, Some("r2".to_owned()), "snap3");
198        r3.timestamp_ms = Some(400);
199        r3.op_kind = Some("edit".to_owned());
200
201        vec![r0, r1, r2, r3]
202    }
203
204    const HEAD: &str = "r3";
205
206    // ── head_forms ────────────────────────────────────────────────────────────
207
208    #[test]
209    fn head_forms() {
210        let records = make_chain();
211        assert_eq!(
212            resolve_revspec(&records, Some(HEAD), "@head").unwrap(),
213            "r3"
214        );
215        assert_eq!(resolve_revspec(&records, Some(HEAD), "HEAD").unwrap(), "r3");
216        assert_eq!(resolve_revspec(&records, Some(HEAD), "@").unwrap(), "r3");
217    }
218
219    // ── head_tilde ────────────────────────────────────────────────────────────
220
221    #[test]
222    fn head_tilde() {
223        let records = make_chain();
224        assert_eq!(
225            resolve_revspec(&records, Some(HEAD), "@head~1").unwrap(),
226            "r2"
227        );
228        assert_eq!(
229            resolve_revspec(&records, Some(HEAD), "HEAD~2").unwrap(),
230            "r1"
231        );
232        assert_eq!(
233            resolve_revspec(&records, Some(HEAD), "@head~0").unwrap(),
234            "r3"
235        );
236        assert_eq!(
237            resolve_revspec(&records, Some(HEAD), "@head^").unwrap(),
238            "r2"
239        );
240    }
241
242    // ── head_tilde_past_root_errors ───────────────────────────────────────────
243
244    #[test]
245    fn head_tilde_past_root_errors() {
246        let records = make_chain();
247        assert!(resolve_revspec(&records, Some(HEAD), "@head~99").is_err());
248    }
249
250    // ── head_no_session_errors ────────────────────────────────────────────────
251
252    #[test]
253    fn head_no_session_errors() {
254        let records = make_chain();
255        assert!(resolve_revspec(&records, None, "@head").is_err());
256    }
257
258    // ── seq_lookup ────────────────────────────────────────────────────────────
259
260    #[test]
261    fn seq_lookup() {
262        let records = make_chain();
263        assert_eq!(resolve_revspec(&records, Some(HEAD), "0").unwrap(), "r0");
264        assert_eq!(resolve_revspec(&records, Some(HEAD), "2").unwrap(), "r2");
265        assert!(resolve_revspec(&records, Some(HEAD), "9").is_err());
266    }
267
268    // ── time_at_or_before ─────────────────────────────────────────────────────
269
270    #[test]
271    fn time_at_or_before() {
272        let records = make_chain();
273        // ts200 is the latest at or before 250
274        assert_eq!(
275            resolve_revspec(&records, Some(HEAD), "@time:250").unwrap(),
276            "r1"
277        );
278        // exact match at 400
279        assert_eq!(
280            resolve_revspec(&records, Some(HEAD), "@time:400").unwrap(),
281            "r3"
282        );
283        // nothing at or before 50
284        assert!(resolve_revspec(&records, Some(HEAD), "@time:50").is_err());
285    }
286
287    // ── latest_label ──────────────────────────────────────────────────────────
288
289    #[test]
290    fn latest_label() {
291        let records = make_chain();
292        // matches op_kind "external" → r1
293        assert_eq!(
294            resolve_revspec(&records, Some(HEAD), "@latest:external").unwrap(),
295            "r1"
296        );
297        // matches label "v1.0" → r2
298        assert_eq!(
299            resolve_revspec(&records, Some(HEAD), "@latest:v1.0").unwrap(),
300            "r2"
301        );
302        // no match
303        assert!(resolve_revspec(&records, Some(HEAD), "@latest:nope").is_err());
304    }
305
306    // ── id_exact_and_prefix ───────────────────────────────────────────────────
307
308    #[test]
309    fn id_exact_and_prefix() {
310        let records = make_chain();
311        // Exact id match.
312        assert_eq!(resolve_revspec(&records, Some(HEAD), "r2").unwrap(), "r2");
313
314        // Unique prefix "r0" is an exact match in this chain, but test a prefix
315        // that is a prefix of exactly one: none of r1/r2/r3 start with "r0".
316        assert_eq!(resolve_revspec(&records, Some(HEAD), "r0").unwrap(), "r0");
317
318        // Build a set where "ra" is ambiguous.
319        let mut extra = records.clone();
320        let ra1 = HistoryRecord::new("ra1", 10, None, "snapA");
321        let ra2 = HistoryRecord::new("ra2", 11, None, "snapB");
322        extra.push(ra1);
323        extra.push(ra2);
324
325        // Ambiguous prefix → error.
326        assert!(resolve_revspec(&extra, Some(HEAD), "ra").is_err());
327        // Exact id "ra1" → success even though "ra" is ambiguous.
328        assert_eq!(resolve_revspec(&extra, Some(HEAD), "ra1").unwrap(), "ra1");
329    }
330
331    // ── malformed ─────────────────────────────────────────────────────────────
332
333    #[test]
334    fn malformed() {
335        let records = make_chain();
336        assert!(resolve_revspec(&records, Some(HEAD), "").is_err());
337        assert!(resolve_revspec(&records, Some(HEAD), "@head~x").is_err());
338        assert!(resolve_revspec(&records, Some(HEAD), "@bogus").is_err());
339    }
340
341    // ── resolve_revspec_for_smoke ─────────────────────────────────────────────
342
343    #[test]
344    fn resolve_revspec_for_smoke() {
345        let fs = MemFs::new();
346        let paths = StorePaths::new("/data");
347        let clock = FakeClock(std::time::SystemTime::UNIX_EPOCH);
348        let rng = FakeRng(0);
349
350        record_state(&fs, &paths, &clock, &rng, "doc1", b"v1", None).unwrap(); // r0
351        record_state(&fs, &paths, &clock, &rng, "doc1", b"v2", None).unwrap(); // r1
352
353        // @head resolves to the current HEAD (r1).
354        let head_id = resolve_revspec_for(&fs, &paths, "doc1", "@head").unwrap();
355        assert_eq!(head_id, "r1");
356
357        // @head~1 resolves to the parent (r0).
358        let parent_id = resolve_revspec_for(&fs, &paths, "doc1", "@head~1").unwrap();
359        assert_eq!(parent_id, "r0");
360    }
361}