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 #[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 #[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#[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 #[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 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 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 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 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}