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    /// Get the currently active branch ref name (like git's HEAD -> refs/heads/X).
183    ///
184    /// Returns `None` for old Views that predate BUG-38 (detached HEAD behaviour).
185    #[must_use]
186    pub fn active_branch(&self) -> Option<&str> {
187        match self.extra.get("active_branch") {
188            Some(Ipld::String(s)) => Some(s.as_str()),
189            _ => None,
190        }
191    }
192
193    /// Set the active branch ref name in extra. Returns `self` for chaining.
194    #[must_use]
195    pub fn with_active_branch(mut self, branch: impl Into<String>) -> Self {
196        self.extra
197            .insert("active_branch".to_string(), Ipld::String(branch.into()));
198        self
199    }
200}
201
202// ---------------- Serde for View ----------------
203
204/// On-wire shape for a single tombstone entry on a View. A list of
205/// these (sorted ascending by `node_id`) is the canonical encoding of
206/// [`View::tombstones`]. We don't key the CBOR map by `NodeId` directly
207/// because DAG-CBOR requires string keys for maps; a list-of-records is
208/// the idiomatic shape for `Map<bytes, struct>` in this codec.
209#[derive(Serialize, Deserialize)]
210struct TombstoneEntry {
211    node_id: NodeId,
212    #[serde(flatten)]
213    tombstone: Tombstone,
214}
215
216#[derive(Serialize, Deserialize)]
217struct ViewWire {
218    #[serde(rename = "_kind")]
219    kind: String,
220    heads: Vec<Cid>,
221    refs: BTreeMap<String, RefTarget>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    remote_refs: Option<BTreeMap<String, BTreeMap<String, RefTarget>>>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    wc_commit: Option<Cid>,
226    /// Sorted-ascending list of tombstone entries. Sorted on `NodeId`
227    /// bytes so two Views with the same logical tombstone set encode
228    /// byte-identically (determinism contract, same rule as `refs`).
229    #[serde(default, skip_serializing_if = "Vec::is_empty")]
230    tombstones: Vec<TombstoneEntry>,
231    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
232    extra: BTreeMap<String, Ipld>,
233}
234
235impl Serialize for View {
236    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
237        // BTreeMap iteration is already sorted, so the emitted list is
238        // sorted-ascending by NodeId bytes.
239        let tombstones: Vec<TombstoneEntry> = self
240            .tombstones
241            .iter()
242            .map(|(id, ts)| TombstoneEntry {
243                node_id: *id,
244                tombstone: ts.clone(),
245            })
246            .collect();
247        ViewWire {
248            kind: Self::KIND.into(),
249            heads: self.heads.clone(),
250            refs: self.refs.clone(),
251            remote_refs: self.remote_refs.clone(),
252            wc_commit: self.wc_commit.clone(),
253            tombstones,
254            extra: self.extra.clone(),
255        }
256        .serialize(serializer)
257    }
258}
259
260impl<'de> Deserialize<'de> for View {
261    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
262        let w = ViewWire::deserialize(deserializer)?;
263        if w.kind != Self::KIND {
264            return Err(serde::de::Error::custom(format!(
265                "expected _kind='{}', got '{}'",
266                Self::KIND,
267                w.kind
268            )));
269        }
270        let mut tombstones = BTreeMap::new();
271        for entry in w.tombstones {
272            tombstones.insert(entry.node_id, entry.tombstone);
273        }
274        Ok(Self {
275            heads: w.heads,
276            refs: w.refs,
277            remote_refs: w.remote_refs,
278            wc_commit: w.wc_commit,
279            tombstones,
280            extra: w.extra,
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::codec::{from_canonical_bytes, to_canonical_bytes};
289    use crate::id::{CODEC_RAW, Multihash};
290
291    fn raw(n: u32) -> Cid {
292        Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
293    }
294
295    #[test]
296    fn empty_view_round_trip() {
297        let original = View::new();
298        let bytes = to_canonical_bytes(&original).unwrap();
299        let decoded: View = from_canonical_bytes(&bytes).unwrap();
300        assert_eq!(original, decoded);
301    }
302
303    #[test]
304    fn view_with_heads_and_refs_round_trip() {
305        let v = View::new()
306            .with_head(raw(1))
307            .with_ref("refs/heads/main", RefTarget::normal(raw(1)))
308            .with_ref(
309                "refs/heads/feature",
310                RefTarget::conflicted(vec![raw(2), raw(3)], vec![raw(1)]),
311            );
312        let bytes = to_canonical_bytes(&v).unwrap();
313        let decoded: View = from_canonical_bytes(&bytes).unwrap();
314        assert_eq!(v, decoded);
315    }
316
317    #[test]
318    fn conflicted_ref_sorts_adds_and_removes() {
319        let r = RefTarget::conflicted(vec![raw(3), raw(1), raw(2)], vec![raw(5), raw(4)]);
320        match r {
321            RefTarget::Conflicted { adds, removes } => {
322                assert!(adds.windows(2).all(|w| w[0] < w[1]));
323                assert!(removes.windows(2).all(|w| w[0] < w[1]));
324            }
325            _ => panic!(),
326        }
327    }
328
329    #[test]
330    fn ref_target_normal_round_trip() {
331        let r = RefTarget::normal(raw(42));
332        let bytes = to_canonical_bytes(&r).unwrap();
333        let decoded: RefTarget = from_canonical_bytes(&bytes).unwrap();
334        assert_eq!(r, decoded);
335    }
336
337    #[test]
338    fn view_with_tracking_refs_round_trip() {
339        // Tracking-refs field on View survives a DAG-CBOR round trip
340        // unchanged; exercises `remote_refs` on the wire shape and
341        // the `with_tracking_ref` / `tracking_ref` helpers that PR 2
342        // added on the remote-transport track.
343        let v = View::new()
344            .with_head(raw(1))
345            .with_ref("refs/heads/main", RefTarget::normal(raw(1)))
346            .with_tracking_ref("origin", "refs/heads/main", raw(10))
347            .with_tracking_ref("origin", "refs/heads/feature", raw(11))
348            .with_tracking_ref("backup", "refs/heads/main", raw(20));
349
350        let bytes = to_canonical_bytes(&v).unwrap();
351        let decoded: View = from_canonical_bytes(&bytes).unwrap();
352        assert_eq!(v, decoded);
353
354        // Helper accessors survive the round-trip identically.
355        assert_eq!(
356            decoded.tracking_ref("origin", "refs/heads/main"),
357            Some(&RefTarget::normal(raw(10))),
358        );
359        assert_eq!(
360            decoded.tracking_ref("backup", "refs/heads/main"),
361            Some(&RefTarget::normal(raw(20))),
362        );
363        assert!(decoded.tracking_ref("unknown", "refs/heads/main").is_none());
364        assert!(
365            decoded
366                .tracking_ref("origin", "refs/heads/missing")
367                .is_none()
368        );
369    }
370
371    #[test]
372    fn view_without_tracking_refs_stays_backward_compatible() {
373        // An empty / unused `remote_refs` field MUST be omitted from
374        // the wire encoding so pre-0.3 Views round-trip byte-identically.
375        let v_without = View::new()
376            .with_head(raw(1))
377            .with_ref("refs/heads/main", RefTarget::normal(raw(1)));
378        let v_with_empty = View::new()
379            .with_head(raw(1))
380            .with_ref("refs/heads/main", RefTarget::normal(raw(1)));
381
382        let a = to_canonical_bytes(&v_without).unwrap();
383        let b = to_canonical_bytes(&v_with_empty).unwrap();
384        assert_eq!(a, b, "empty remote_refs must not change bytes");
385    }
386
387    #[test]
388    fn view_kind_rejection() {
389        let w = ViewWire {
390            kind: "commit".into(),
391            heads: Vec::new(),
392            refs: BTreeMap::new(),
393            remote_refs: None,
394            wc_commit: None,
395            tombstones: Vec::new(),
396            extra: BTreeMap::new(),
397        };
398        let bytes = serde_ipld_dagcbor::to_vec(&w).unwrap();
399        let err = serde_ipld_dagcbor::from_slice::<View>(&bytes).unwrap_err();
400        assert!(err.to_string().contains("_kind"));
401    }
402}