Skip to main content

mnem_core/objects/
view.rs

1//! View object (SPEC §4.6) - a snapshot of the mutable state of a repo.
2//!
3//! Carries:
4//!
5//! - `heads` - current head commits (≥1, or 0 for the root View per §7.5)
6//! - `refs` - named references (branches, tags) as a map of name → [`RefTarget`]
7//! - `remote_refs` - optional per-remote named references
8//! - `wc_commit` - optional working-copy pointer
9//!
10//! `RefTargets` are either `Normal(Cid)` or `Conflicted { adds, removes }`;
11//! see SPEC §4.6 and amendments.
12
13use std::collections::BTreeMap;
14
15use ipld_core::ipld::Ipld;
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17
18use crate::id::{Cid, NodeId};
19use crate::objects::tombstone::Tombstone;
20
21/// A named reference in a [`View`].
22///
23/// Per SPEC §4.6 the on-wire form has a `kind` discriminator:
24///
25/// ```text
26/// { "kind": "normal",     "target": Link<Commit> }
27/// { "kind": "conflicted", "adds": [Link], "removes": [Link] }
28/// ```
29///
30/// Canonical form for `Conflicted`: `adds` and `removes` MUST each be
31/// strictly ascending by CID byte representation, with no duplicates
32/// and not both empty. See SPEC §4.6 amendments.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "lowercase")]
35pub enum RefTarget {
36    /// A single Commit target.
37    Normal {
38        /// The commit this ref points at.
39        target: Cid,
40    },
41    /// Unresolved concurrent-update state. Canonical form sorts
42    /// `adds` and `removes` ascending.
43    Conflicted {
44        /// Candidate new targets.
45        adds: Vec<Cid>,
46        /// Previously-observed targets removed on one side of the merge.
47        removes: Vec<Cid>,
48    },
49}
50
51impl RefTarget {
52    /// Construct a normal ref pointing at `target`.
53    #[must_use]
54    pub const fn normal(target: Cid) -> Self {
55        Self::Normal { target }
56    }
57
58    /// Construct a conflicted ref, sorting `adds` and `removes`
59    /// canonically.
60    #[must_use]
61    pub fn conflicted(mut adds: Vec<Cid>, mut removes: Vec<Cid>) -> Self {
62        adds.sort();
63        adds.dedup();
64        removes.sort();
65        removes.dedup();
66        Self::Conflicted { adds, removes }
67    }
68}
69
70/// A snapshot of the repository's mutable state at a single instant.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct View {
73    /// Current head commits.
74    pub heads: Vec<Cid>,
75    /// Named references.
76    pub refs: BTreeMap<String, RefTarget>,
77    /// Per-remote named references. Outer key is the remote name
78    /// (e.g. `"origin"` matching a `[remote.origin]` section in
79    /// `.mnem/config.toml`); inner map is that remote's server-side
80    /// refs (e.g. `"refs/heads/main"` → `RefTarget`) as observed on
81    /// the last `mnem fetch`. PR 2 on the remote-transport track
82    /// adds the [`View::with_tracking_ref`] / [`View::tracking_ref`]
83    /// helpers; PR 3 wires up the actual network fetch. Absent /
84    /// empty maps are omitted from the wire encoding so pre-0.3
85    /// Views round-trip byte-identically. See .
86    ///
87    ///
88    pub remote_refs: Option<BTreeMap<String, BTreeMap<String, RefTarget>>>,
89    /// Working-copy commit pointer.
90    pub wc_commit: Option<Cid>,
91    /// Logical "forget this node" markers (SPEC §4.10, mnem/0.2+).
92    ///
93    /// Maps a [`NodeId`] to the [`Tombstone`] record that revoked it.
94    /// The underlying Node block stays in the node Prolly tree; its CID
95    /// is unchanged. Retrieval paths filter out tombstoned nodes by
96    /// default (see
97    /// [`crate::retrieve::Retriever::include_tombstoned`]).
98    ///
99    /// Re-tombstoning the same `NodeId` overwrites the previous entry.
100    /// Store shape mirrors `remote_refs`: inline `BTreeMap`, encoded as
101    /// an optional list that is skipped on the wire when empty. That
102    /// keeps pre-0.2 Views byte-identical after a round-trip through a
103    /// newer decoder.
104    pub tombstones: BTreeMap<NodeId, Tombstone>,
105    /// Forward-compat extension map (SPEC §3.2).
106    pub extra: BTreeMap<String, Ipld>,
107}
108
109impl Default for View {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl View {
116    /// The `_kind` discriminator on the wire.
117    pub const KIND: &'static str = "view";
118
119    /// An empty View (no heads, no refs). The root View of a freshly-
120    /// initialized repository (SPEC §7.5).
121    #[must_use]
122    pub const fn new() -> Self {
123        Self {
124            heads: Vec::new(),
125            refs: BTreeMap::new(),
126            remote_refs: None,
127            wc_commit: None,
128            tombstones: BTreeMap::new(),
129            extra: BTreeMap::new(),
130        }
131    }
132
133    /// Add a head commit. Returns `self` for chaining.
134    #[must_use]
135    pub fn with_head(mut self, head: Cid) -> Self {
136        self.heads.push(head);
137        self
138    }
139
140    /// Add a named ref. Returns `self` for chaining.
141    #[must_use]
142    pub fn with_ref(mut self, name: impl Into<String>, target: RefTarget) -> Self {
143        self.refs.insert(name.into(), target);
144        self
145    }
146
147    /// Record a tracking ref for a named remote, e.g. after a `mnem
148    /// fetch origin` converges the server's `refs/heads/main` to a
149    /// local `origin/main` pointer. `remote` is the short name
150    /// registered in `.mnem/config.toml` (`[remote.origin]`),
151    /// `ref_name` is the server-side refname (`refs/heads/main`),
152    /// and `target` is the Commit CID the remote had for it at fetch
153    /// time. Subsequent fetches overwrite.
154    ///
155    /// Returns `self` for chaining. Lazily allocates the
156    /// `remote_refs` map; empty Views still encode to the pre-0.3
157    /// byte sequence (the map is omitted when empty).
158    #[must_use]
159    pub fn with_tracking_ref(
160        mut self,
161        remote: impl Into<String>,
162        ref_name: impl Into<String>,
163        target: Cid,
164    ) -> Self {
165        let remote = remote.into();
166        let ref_name = ref_name.into();
167        let rt = RefTarget::normal(target);
168        let map = self.remote_refs.get_or_insert_with(BTreeMap::new);
169        map.entry(remote).or_default().insert(ref_name, rt);
170        self
171    }
172
173    /// Convenience accessor: the tracking ref the last `mnem fetch`
174    /// recorded for a `{remote, ref_name}` pair, or `None` if the
175    /// remote is unknown or does not carry that ref. Mirrors the
176    /// Git behaviour of `git rev-parse origin/main`.
177    #[must_use]
178    pub fn tracking_ref(&self, remote: &str, ref_name: &str) -> Option<&RefTarget> {
179        self.remote_refs.as_ref()?.get(remote)?.get(ref_name)
180    }
181}
182
183// ---------------- Serde for View ----------------
184
185/// On-wire shape for a single tombstone entry on a View. A list of
186/// these (sorted ascending by `node_id`) is the canonical encoding of
187/// [`View::tombstones`]. We don't key the CBOR map by `NodeId` directly
188/// because DAG-CBOR requires string keys for maps; a list-of-records is
189/// the idiomatic shape for `Map<bytes, struct>` in this codec.
190#[derive(Serialize, Deserialize)]
191struct TombstoneEntry {
192    node_id: NodeId,
193    #[serde(flatten)]
194    tombstone: Tombstone,
195}
196
197#[derive(Serialize, Deserialize)]
198struct ViewWire {
199    #[serde(rename = "_kind")]
200    kind: String,
201    heads: Vec<Cid>,
202    refs: BTreeMap<String, RefTarget>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    remote_refs: Option<BTreeMap<String, BTreeMap<String, RefTarget>>>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    wc_commit: Option<Cid>,
207    /// Sorted-ascending list of tombstone entries. Sorted on `NodeId`
208    /// bytes so two Views with the same logical tombstone set encode
209    /// byte-identically (determinism contract, same rule as `refs`).
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    tombstones: Vec<TombstoneEntry>,
212    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
213    extra: BTreeMap<String, Ipld>,
214}
215
216impl Serialize for View {
217    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
218        // BTreeMap iteration is already sorted, so the emitted list is
219        // sorted-ascending by NodeId bytes.
220        let tombstones: Vec<TombstoneEntry> = self
221            .tombstones
222            .iter()
223            .map(|(id, ts)| TombstoneEntry {
224                node_id: *id,
225                tombstone: ts.clone(),
226            })
227            .collect();
228        ViewWire {
229            kind: Self::KIND.into(),
230            heads: self.heads.clone(),
231            refs: self.refs.clone(),
232            remote_refs: self.remote_refs.clone(),
233            wc_commit: self.wc_commit.clone(),
234            tombstones,
235            extra: self.extra.clone(),
236        }
237        .serialize(serializer)
238    }
239}
240
241impl<'de> Deserialize<'de> for View {
242    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
243        let w = ViewWire::deserialize(deserializer)?;
244        if w.kind != Self::KIND {
245            return Err(serde::de::Error::custom(format!(
246                "expected _kind='{}', got '{}'",
247                Self::KIND,
248                w.kind
249            )));
250        }
251        let mut tombstones = BTreeMap::new();
252        for entry in w.tombstones {
253            tombstones.insert(entry.node_id, entry.tombstone);
254        }
255        Ok(Self {
256            heads: w.heads,
257            refs: w.refs,
258            remote_refs: w.remote_refs,
259            wc_commit: w.wc_commit,
260            tombstones,
261            extra: w.extra,
262        })
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::codec::{from_canonical_bytes, to_canonical_bytes};
270    use crate::id::{CODEC_RAW, Multihash};
271
272    fn raw(n: u32) -> Cid {
273        Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
274    }
275
276    #[test]
277    fn empty_view_round_trip() {
278        let original = View::new();
279        let bytes = to_canonical_bytes(&original).unwrap();
280        let decoded: View = from_canonical_bytes(&bytes).unwrap();
281        assert_eq!(original, decoded);
282    }
283
284    #[test]
285    fn view_with_heads_and_refs_round_trip() {
286        let v = View::new()
287            .with_head(raw(1))
288            .with_ref("refs/heads/main", RefTarget::normal(raw(1)))
289            .with_ref(
290                "refs/heads/feature",
291                RefTarget::conflicted(vec![raw(2), raw(3)], vec![raw(1)]),
292            );
293        let bytes = to_canonical_bytes(&v).unwrap();
294        let decoded: View = from_canonical_bytes(&bytes).unwrap();
295        assert_eq!(v, decoded);
296    }
297
298    #[test]
299    fn conflicted_ref_sorts_adds_and_removes() {
300        let r = RefTarget::conflicted(vec![raw(3), raw(1), raw(2)], vec![raw(5), raw(4)]);
301        match r {
302            RefTarget::Conflicted { adds, removes } => {
303                assert!(adds.windows(2).all(|w| w[0] < w[1]));
304                assert!(removes.windows(2).all(|w| w[0] < w[1]));
305            }
306            _ => panic!(),
307        }
308    }
309
310    #[test]
311    fn ref_target_normal_round_trip() {
312        let r = RefTarget::normal(raw(42));
313        let bytes = to_canonical_bytes(&r).unwrap();
314        let decoded: RefTarget = from_canonical_bytes(&bytes).unwrap();
315        assert_eq!(r, decoded);
316    }
317
318    #[test]
319    fn view_with_tracking_refs_round_trip() {
320        // Tracking-refs field on View survives a DAG-CBOR round trip
321        // unchanged; exercises `remote_refs` on the wire shape and
322        // the `with_tracking_ref` / `tracking_ref` helpers that PR 2
323        // added on the remote-transport track.
324        let v = View::new()
325            .with_head(raw(1))
326            .with_ref("refs/heads/main", RefTarget::normal(raw(1)))
327            .with_tracking_ref("origin", "refs/heads/main", raw(10))
328            .with_tracking_ref("origin", "refs/heads/feature", raw(11))
329            .with_tracking_ref("backup", "refs/heads/main", raw(20));
330
331        let bytes = to_canonical_bytes(&v).unwrap();
332        let decoded: View = from_canonical_bytes(&bytes).unwrap();
333        assert_eq!(v, decoded);
334
335        // Helper accessors survive the round-trip identically.
336        assert_eq!(
337            decoded.tracking_ref("origin", "refs/heads/main"),
338            Some(&RefTarget::normal(raw(10))),
339        );
340        assert_eq!(
341            decoded.tracking_ref("backup", "refs/heads/main"),
342            Some(&RefTarget::normal(raw(20))),
343        );
344        assert!(decoded.tracking_ref("unknown", "refs/heads/main").is_none());
345        assert!(
346            decoded
347                .tracking_ref("origin", "refs/heads/missing")
348                .is_none()
349        );
350    }
351
352    #[test]
353    fn view_without_tracking_refs_stays_backward_compatible() {
354        // An empty / unused `remote_refs` field MUST be omitted from
355        // the wire encoding so pre-0.3 Views round-trip byte-identically.
356        let v_without = View::new()
357            .with_head(raw(1))
358            .with_ref("refs/heads/main", RefTarget::normal(raw(1)));
359        let v_with_empty = View::new()
360            .with_head(raw(1))
361            .with_ref("refs/heads/main", RefTarget::normal(raw(1)));
362
363        let a = to_canonical_bytes(&v_without).unwrap();
364        let b = to_canonical_bytes(&v_with_empty).unwrap();
365        assert_eq!(a, b, "empty remote_refs must not change bytes");
366    }
367
368    #[test]
369    fn view_kind_rejection() {
370        let w = ViewWire {
371            kind: "commit".into(),
372            heads: Vec::new(),
373            refs: BTreeMap::new(),
374            remote_refs: None,
375            wc_commit: None,
376            tombstones: Vec::new(),
377            extra: BTreeMap::new(),
378        };
379        let bytes = serde_ipld_dagcbor::to_vec(&w).unwrap();
380        let err = serde_ipld_dagcbor::from_slice::<View>(&bytes).unwrap_err();
381        assert!(err.to_string().contains("_kind"));
382    }
383}