1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "lowercase")]
35pub enum RefTarget {
36 Normal {
38 target: Cid,
40 },
41 Conflicted {
44 adds: Vec<Cid>,
46 removes: Vec<Cid>,
48 },
49}
50
51impl RefTarget {
52 #[must_use]
54 pub const fn normal(target: Cid) -> Self {
55 Self::Normal { target }
56 }
57
58 #[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#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct View {
73 pub heads: Vec<Cid>,
75 pub refs: BTreeMap<String, RefTarget>,
77 pub remote_refs: Option<BTreeMap<String, BTreeMap<String, RefTarget>>>,
89 pub wc_commit: Option<Cid>,
91 pub tombstones: BTreeMap<NodeId, Tombstone>,
105 pub extra: BTreeMap<String, Ipld>,
107}
108
109impl Default for View {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115impl View {
116 pub const KIND: &'static str = "view";
118
119 #[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 #[must_use]
135 pub fn with_head(mut self, head: Cid) -> Self {
136 self.heads.push(head);
137 self
138 }
139
140 #[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 #[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 #[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#[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 #[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 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 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 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 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}