Skip to main content

heddle_object_model/object/
tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree types: entries, structure, and supporting enums.
3
4use std::{fmt, path::Path, sync::Arc};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
8
9use super::{ContentHash, SpoolId, StateId};
10
11const TREE_FORMAT_VERSION: u8 = 3;
12const ENTRY_KIND_BLOB: u8 = 0;
13const ENTRY_KIND_TREE: u8 = 1;
14const ENTRY_KIND_SYMLINK: u8 = 2;
15const ENTRY_KIND_GITLINK: u8 = 3;
16/// Native child-spool edge: the entry's payload is a spool-id + anchored
17/// state-id, not a git commit OID. This link is
18/// deliberately NOT a git submodule — see [`FileMode::Spoollink`].
19const ENTRY_KIND_SPOOLLINK: u8 = 4;
20const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
21const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
22
23// ── TreeError ───────────────────────────────────────────────────────
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TreeError {
27    InvalidName(String),
28    InvalidStructure(String),
29}
30
31impl std::error::Error for TreeError {}
32
33impl fmt::Display for TreeError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            TreeError::InvalidName(msg) => write!(f, "invalid tree entry name: {}", msg),
37            TreeError::InvalidStructure(msg) => write!(f, "invalid tree structure: {}", msg),
38        }
39    }
40}
41
42// ── FileMode ────────────────────────────────────────────────────────
43
44#[repr(u8)]
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum FileMode {
47    Normal,
48    Executable,
49    Symlink,
50    Gitlink,
51    /// Native child-spool edge. This is NOT a git file mode: a spoollink
52    /// points at a spool-id + state-id, not a git object, so it has no valid
53    /// git submodule (`160000`) representation and [`Self::to_unix_mode`]
54    /// returns `0`. Git-boundary code MUST handle it explicitly rather than
55    /// emit a bogus mode.
56    Spoollink,
57}
58
59impl FileMode {
60    pub fn to_byte(&self) -> u8 {
61        match self {
62            FileMode::Normal => 0,
63            FileMode::Executable => 1,
64            FileMode::Symlink => 2,
65            FileMode::Gitlink => 3,
66            FileMode::Spoollink => 4,
67        }
68    }
69
70    pub fn from_byte(b: u8) -> Option<Self> {
71        match b {
72            0 => Some(FileMode::Normal),
73            1 => Some(FileMode::Executable),
74            2 => Some(FileMode::Symlink),
75            3 => Some(FileMode::Gitlink),
76            4 => Some(FileMode::Spoollink),
77            _ => None,
78        }
79    }
80
81    /// The git tree/index mode for this entry. A spoollink has no git mode
82    /// (it is not a git object) and returns `0` — callers on a git boundary
83    /// must skip spoollinks rather than treat this as a real mode.
84    pub fn to_unix_mode(&self) -> u32 {
85        match self {
86            FileMode::Normal => 0o100644,
87            FileMode::Executable => 0o100755,
88            FileMode::Symlink => 0o120000,
89            FileMode::Gitlink => 0o160000,
90            FileMode::Spoollink => 0,
91        }
92    }
93}
94
95// ── EntryType ───────────────────────────────────────────────────────
96
97#[repr(u8)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EntryType {
100    Blob,
101    Tree,
102    Symlink,
103    Gitlink,
104    /// Native child-spool edge (see [`TreeEntryTarget::Spoollink`]).
105    Spoollink,
106}
107
108impl EntryType {
109    pub fn to_byte(&self) -> u8 {
110        match self {
111            EntryType::Blob => 0,
112            EntryType::Tree => 1,
113            EntryType::Symlink => 2,
114            EntryType::Gitlink => 3,
115            EntryType::Spoollink => 4,
116        }
117    }
118
119    pub fn from_byte(b: u8) -> Option<Self> {
120        match b {
121            0 => Some(EntryType::Blob),
122            1 => Some(EntryType::Tree),
123            2 => Some(EntryType::Symlink),
124            3 => Some(EntryType::Gitlink),
125            4 => Some(EntryType::Spoollink),
126            _ => None,
127        }
128    }
129}
130
131// ── TreeEntryTarget ────────────────────────────────────────────────
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum TreeEntryTarget {
135    Blob {
136        hash: ContentHash,
137        executable: bool,
138    },
139    Tree {
140        hash: ContentHash,
141    },
142    Symlink {
143        hash: ContentHash,
144    },
145    Gitlink {
146        target: GitObjectId,
147    },
148    /// Native pointer to a child spool: a spool-id plus an anchored state-id.
149    /// Unlike [`Self::Gitlink`], this is NOT a git object OID and cannot
150    /// round-trip to a git submodule; git-boundary code must handle it
151    /// explicitly (skip on export). The Spool children facet consumes this in
152    /// a later phase.
153    Spoollink {
154        spool_id: SpoolId,
155        state_id: StateId,
156    },
157}
158
159impl TreeEntryTarget {
160    pub fn entry_type(&self) -> EntryType {
161        match self {
162            TreeEntryTarget::Blob { .. } => EntryType::Blob,
163            TreeEntryTarget::Tree { .. } => EntryType::Tree,
164            TreeEntryTarget::Symlink { .. } => EntryType::Symlink,
165            TreeEntryTarget::Gitlink { .. } => EntryType::Gitlink,
166            TreeEntryTarget::Spoollink { .. } => EntryType::Spoollink,
167        }
168    }
169
170    pub fn mode(&self) -> FileMode {
171        match self {
172            TreeEntryTarget::Blob {
173                executable: true, ..
174            } => FileMode::Executable,
175            TreeEntryTarget::Blob { .. } => FileMode::Normal,
176            TreeEntryTarget::Tree { .. } => FileMode::Normal,
177            TreeEntryTarget::Symlink { .. } => FileMode::Symlink,
178            TreeEntryTarget::Gitlink { .. } => FileMode::Gitlink,
179            TreeEntryTarget::Spoollink { .. } => FileMode::Spoollink,
180        }
181    }
182
183    pub fn content_hash(&self) -> Option<ContentHash> {
184        match self {
185            TreeEntryTarget::Blob { hash, .. }
186            | TreeEntryTarget::Tree { hash }
187            | TreeEntryTarget::Symlink { hash } => Some(*hash),
188            TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => None,
189        }
190    }
191
192    pub fn gitlink_target(&self) -> Option<GitObjectId> {
193        match self {
194            TreeEntryTarget::Gitlink { target } => Some(*target),
195            _ => None,
196        }
197    }
198
199    /// The child-spool pointer `(spool_id, state_id)` for a spoollink entry,
200    /// or `None` for any other kind.
201    pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
202        match self {
203            TreeEntryTarget::Spoollink { spool_id, state_id } => Some((spool_id, *state_id)),
204            _ => None,
205        }
206    }
207
208    fn encoded_payload_len(&self) -> usize {
209        match self {
210            TreeEntryTarget::Blob { hash, .. }
211            | TreeEntryTarget::Tree { hash }
212            | TreeEntryTarget::Symlink { hash } => hash.as_bytes().len(),
213            TreeEntryTarget::Gitlink { target } => target.as_bytes().len(),
214            TreeEntryTarget::Spoollink { spool_id, state_id } => {
215                4 + spool_id.as_str().len() + state_id.as_bytes().len()
216            }
217        }
218    }
219
220    fn update_hasher(&self, hasher: &mut blake3::Hasher) {
221        hasher.update(&[self.mode().to_byte()]);
222        hasher.update(&[self.entry_type().to_byte()]);
223        match self {
224            TreeEntryTarget::Blob { hash, .. }
225            | TreeEntryTarget::Tree { hash }
226            | TreeEntryTarget::Symlink { hash } => hasher.update(hash.as_bytes()),
227            TreeEntryTarget::Gitlink { target } => {
228                hasher.update(&[git_format_to_tag(target.format())]);
229                hasher.update(target.as_bytes())
230            }
231            TreeEntryTarget::Spoollink { spool_id, state_id } => {
232                hasher.update(&(spool_id.as_str().len() as u32).to_le_bytes());
233                hasher.update(spool_id.as_str().as_bytes());
234                hasher.update(state_id.as_bytes())
235            }
236        };
237    }
238}
239
240// ── TreeEntry ───────────────────────────────────────────────────────
241
242pub fn validate_name(name: &str) -> Result<(), TreeError> {
243    if name.is_empty() {
244        return Err(TreeError::InvalidName("entry name cannot be empty".into()));
245    }
246    if name == "." || name == ".." {
247        return Err(TreeError::InvalidName(format!(
248            "'{}' is not a valid entry name",
249            name
250        )));
251    }
252    if name.contains('/') || name.contains('\\') {
253        return Err(TreeError::InvalidName(
254            "entry name cannot contain path separators".into(),
255        ));
256    }
257    if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
258        return Err(TreeError::InvalidName(
259            "entry name contains control characters".into(),
260        ));
261    }
262    if name.len() > u16::MAX as usize {
263        return Err(TreeError::InvalidName(
264            "entry name exceeds the HTR4 u16 length bound".into(),
265        ));
266    }
267    Ok(())
268}
269
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct TreeEntry {
272    name: String,
273    target: TreeEntryTarget,
274}
275
276impl TreeEntry {
277    pub(crate) fn validate(&self) -> Result<(), TreeError> {
278        validate_name(&self.name)
279    }
280
281    pub fn file(
282        name: impl Into<String>,
283        hash: ContentHash,
284        executable: bool,
285    ) -> Result<Self, TreeError> {
286        let name = name.into();
287        validate_name(&name)?;
288        Ok(Self {
289            name,
290            target: TreeEntryTarget::Blob { hash, executable },
291        })
292    }
293
294    pub fn directory(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
295        let name = name.into();
296        validate_name(&name)?;
297        Ok(Self {
298            name,
299            target: TreeEntryTarget::Tree { hash },
300        })
301    }
302
303    pub fn symlink(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
304        let name = name.into();
305        validate_name(&name)?;
306        Ok(Self {
307            name,
308            target: TreeEntryTarget::Symlink { hash },
309        })
310    }
311
312    pub fn gitlink(name: impl Into<String>, target: GitObjectId) -> Result<Self, TreeError> {
313        let name = name.into();
314        validate_name(&name)?;
315        Ok(Self {
316            name,
317            target: TreeEntryTarget::Gitlink { target },
318        })
319    }
320
321    /// Build a native child-spool edge: a pointer to `spool_id` anchored at
322    /// `state_id`. Not a git submodule (see [`TreeEntryTarget::Spoollink`]).
323    pub fn spoollink(
324        name: impl Into<String>,
325        spool_id: SpoolId,
326        state_id: StateId,
327    ) -> Result<Self, TreeError> {
328        let name = name.into();
329        validate_name(&name)?;
330        Ok(Self {
331            name,
332            target: TreeEntryTarget::Spoollink { spool_id, state_id },
333        })
334    }
335
336    pub fn name(&self) -> &str {
337        &self.name
338    }
339
340    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), TreeError> {
341        let name = name.into();
342        validate_name(&name)?;
343        self.name = name;
344        Ok(())
345    }
346
347    pub fn with_mode(&self, mode: FileMode) -> Result<Self, TreeError> {
348        match (&self.target, mode) {
349            (TreeEntryTarget::Blob { hash, .. }, FileMode::Normal | FileMode::Executable) => {
350                Self::file(self.name.clone(), *hash, mode == FileMode::Executable)
351            }
352            (TreeEntryTarget::Symlink { .. }, FileMode::Symlink)
353            | (TreeEntryTarget::Tree { .. }, _)
354            | (TreeEntryTarget::Gitlink { .. }, FileMode::Gitlink)
355            | (TreeEntryTarget::Spoollink { .. }, FileMode::Spoollink)
356                if mode == self.mode() =>
357            {
358                Ok(self.clone())
359            }
360            _ => Err(TreeError::InvalidStructure(format!(
361                "cannot apply mode {:?} to {:?} entry '{}'",
362                mode,
363                self.entry_type(),
364                self.name
365            ))),
366        }
367    }
368
369    pub fn target(&self) -> &TreeEntryTarget {
370        &self.target
371    }
372
373    pub fn entry_type(&self) -> EntryType {
374        self.target.entry_type()
375    }
376
377    pub fn mode(&self) -> FileMode {
378        self.target.mode()
379    }
380
381    pub fn content_hash(&self) -> Option<ContentHash> {
382        self.target.content_hash()
383    }
384
385    pub fn leaf_content_hash(&self) -> Option<ContentHash> {
386        match self.target {
387            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => Some(hash),
388            TreeEntryTarget::Tree { .. }
389            | TreeEntryTarget::Gitlink { .. }
390            | TreeEntryTarget::Spoollink { .. } => None,
391        }
392    }
393
394    pub fn require_content_hash(&self) -> ContentHash {
395        self.content_hash()
396            .expect("tree entry target does not carry a Heddle content hash")
397    }
398
399    pub fn blob_hash(&self) -> Option<ContentHash> {
400        match self.target {
401            TreeEntryTarget::Blob { hash, .. } => Some(hash),
402            _ => None,
403        }
404    }
405
406    pub fn tree_hash(&self) -> Option<ContentHash> {
407        match self.target {
408            TreeEntryTarget::Tree { hash } => Some(hash),
409            _ => None,
410        }
411    }
412
413    pub fn symlink_hash(&self) -> Option<ContentHash> {
414        match self.target {
415            TreeEntryTarget::Symlink { hash } => Some(hash),
416            _ => None,
417        }
418    }
419
420    pub fn gitlink_target(&self) -> Option<GitObjectId> {
421        self.target.gitlink_target()
422    }
423
424    /// The `(spool_id, state_id)` pointer for a spoollink entry, else `None`.
425    pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
426        self.target.spoollink_target()
427    }
428
429    pub fn is_tree(&self) -> bool {
430        self.entry_type() == EntryType::Tree
431    }
432
433    pub fn is_blob(&self) -> bool {
434        self.entry_type() == EntryType::Blob
435    }
436
437    pub fn is_symlink(&self) -> bool {
438        self.entry_type() == EntryType::Symlink
439    }
440
441    pub fn is_gitlink(&self) -> bool {
442        self.entry_type() == EntryType::Gitlink
443    }
444
445    pub fn is_spoollink(&self) -> bool {
446        self.entry_type() == EntryType::Spoollink
447    }
448
449    pub fn is_executable(&self) -> bool {
450        self.mode() == FileMode::Executable
451    }
452
453    pub(crate) fn encoded_len(&self) -> usize {
454        1 + 1 + self.target.encoded_payload_len() + self.name.len() + 1
455    }
456
457    /// Owned name-plus-target bytes used by streaming page budgets.
458    pub fn decoded_size(&self) -> usize {
459        self.name.len() + self.target.encoded_payload_len()
460    }
461
462    pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
463        self.target.update_hasher(hasher);
464        hasher.update(self.name.as_bytes());
465        hasher.update(&[0]);
466    }
467}
468
469// ── Tree ────────────────────────────────────────────────────────────
470
471#[derive(Clone, Debug, PartialEq, Eq)]
472pub struct Tree {
473    // Trees are immutable on every read path and only change while a caller is
474    // constructing a replacement tree. Sharing the entry vector makes those
475    // read-path clones O(1); insert/remove detach with copy-on-write.
476    entries: Arc<Vec<TreeEntry>>,
477}
478
479impl Tree {
480    pub fn new() -> Self {
481        Self {
482            entries: Arc::new(Vec::new()),
483        }
484    }
485
486    pub fn from_entries(mut entries: Vec<TreeEntry>) -> Self {
487        entries.sort_by(|a, b| a.name.cmp(&b.name));
488        Self {
489            entries: Arc::new(entries),
490        }
491    }
492
493    /// Build a tree from entries that are already in canonical name order.
494    ///
495    /// Unlike [`Self::from_entries`], this does not sort. Decoders use it so
496    /// eager and streaming paths reject the same out-of-order or duplicate
497    /// encodings instead of silently canonicalizing them.
498    pub fn try_from_decoded_entries(entries: Vec<TreeEntry>) -> Result<Self, TreeError> {
499        let tree = Self {
500            entries: Arc::new(entries),
501        };
502        tree.validate()?;
503        Ok(tree)
504    }
505
506    pub fn validate(&self) -> Result<(), TreeError> {
507        let mut previous_name: Option<&str> = None;
508        for entry in self.entries.iter() {
509            entry.validate()?;
510            if let Some(previous) = previous_name
511                && previous >= entry.name.as_str()
512            {
513                return Err(TreeError::InvalidStructure(
514                    "entries must be strictly sorted by name".to_string(),
515                ));
516            }
517            previous_name = Some(&entry.name);
518        }
519        Ok(())
520    }
521
522    pub fn entries(&self) -> &[TreeEntry] {
523        &self.entries
524    }
525
526    pub fn get(&self, name: &str) -> Option<&TreeEntry> {
527        let index = self
528            .entries
529            .binary_search_by(|entry| entry.name.as_str().cmp(name))
530            .ok()?;
531        self.entries.get(index)
532    }
533
534    pub fn insert(&mut self, entry: TreeEntry) {
535        let entries = Arc::make_mut(&mut self.entries);
536        entries.retain(|e| e.name != entry.name);
537        let pos = entries
538            .iter()
539            .position(|e| e.name > entry.name)
540            .unwrap_or(entries.len());
541        entries.insert(pos, entry);
542    }
543
544    pub fn remove(&mut self, name: &str) -> Option<TreeEntry> {
545        let pos = self.entries.iter().position(|e| e.name == name)?;
546        Some(Arc::make_mut(&mut self.entries).remove(pos))
547    }
548
549    pub fn is_empty(&self) -> bool {
550        self.entries.is_empty()
551    }
552
553    pub fn len(&self) -> usize {
554        self.entries.len()
555    }
556
557    pub fn hash(&self) -> ContentHash {
558        let total_len: usize = self.entries.iter().map(TreeEntry::encoded_len).sum();
559        ContentHash::compute_typed_with_len("tree", total_len as u64, |hasher| {
560            for entry in self.entries.iter() {
561                entry.update_hasher(hasher);
562            }
563        })
564    }
565
566    pub fn iter(&self) -> impl Iterator<Item = &TreeEntry> {
567        self.entries.iter()
568    }
569
570    pub fn get_path(&self, path: &Path) -> Option<&TreeEntry> {
571        let name = path.file_name()?.to_str()?;
572        if path.parent().is_none_or(|p| p.as_os_str().is_empty()) {
573            self.get(name)
574        } else {
575            None
576        }
577    }
578}
579
580// ── Durable V2 tree encoding ───────────────────────────────────────
581
582#[derive(Serialize, Deserialize)]
583struct EncodedTreeV2 {
584    version: u8,
585    entries: Vec<EncodedTreeEntryV2>,
586}
587
588#[derive(Serialize, Deserialize)]
589struct EncodedTreeEntryV2 {
590    name: String,
591    kind: u8,
592    hash: Option<ContentHash>,
593    executable: Option<bool>,
594    git_format: Option<u8>,
595    git_oid: Option<Vec<u8>>,
596    // Child-spool pointer for SPOOLLINK entries. `default`
597    // keeps the encoding backward-compatible: pre-SPOOLLINK payloads simply
598    // omit these fields.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    spool_id: Option<SpoolId>,
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    spool_state_id: Option<StateId>,
603}
604
605impl Serialize for Tree {
606    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
607    where
608        S: Serializer,
609    {
610        EncodedTreeV2::from(self).serialize(serializer)
611    }
612}
613
614impl<'de> Deserialize<'de> for Tree {
615    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
616    where
617        D: Deserializer<'de>,
618    {
619        let encoded = EncodedTreeV2::deserialize(deserializer)?;
620        Tree::try_from(encoded).map_err(de::Error::custom)
621    }
622}
623
624#[derive(Debug)]
625pub enum TreeDecodeError {
626    Decode(rmp_serde::decode::Error),
627    Invalid(TreeError),
628}
629
630impl From<rmp_serde::decode::Error> for TreeDecodeError {
631    fn from(error: rmp_serde::decode::Error) -> Self {
632        Self::Decode(error)
633    }
634}
635
636impl From<TreeError> for TreeDecodeError {
637    fn from(error: TreeError) -> Self {
638        Self::Invalid(error)
639    }
640}
641
642impl From<&Tree> for EncodedTreeV2 {
643    fn from(tree: &Tree) -> Self {
644        Self {
645            version: TREE_FORMAT_VERSION,
646            entries: tree.entries.iter().map(EncodedTreeEntryV2::from).collect(),
647        }
648    }
649}
650
651impl From<&TreeEntry> for EncodedTreeEntryV2 {
652    fn from(entry: &TreeEntry) -> Self {
653        match entry.target() {
654            TreeEntryTarget::Blob { hash, executable } => Self {
655                name: entry.name.clone(),
656                kind: ENTRY_KIND_BLOB,
657                hash: Some(*hash),
658                executable: Some(*executable),
659                git_format: None,
660                git_oid: None,
661                spool_id: None,
662                spool_state_id: None,
663            },
664            TreeEntryTarget::Tree { hash } => Self {
665                name: entry.name.clone(),
666                kind: ENTRY_KIND_TREE,
667                hash: Some(*hash),
668                executable: None,
669                git_format: None,
670                git_oid: None,
671                spool_id: None,
672                spool_state_id: None,
673            },
674            TreeEntryTarget::Symlink { hash } => Self {
675                name: entry.name.clone(),
676                kind: ENTRY_KIND_SYMLINK,
677                hash: Some(*hash),
678                executable: None,
679                git_format: None,
680                git_oid: None,
681                spool_id: None,
682                spool_state_id: None,
683            },
684            TreeEntryTarget::Gitlink { target } => Self {
685                name: entry.name.clone(),
686                kind: ENTRY_KIND_GITLINK,
687                hash: None,
688                executable: None,
689                git_format: Some(git_format_to_tag(target.format())),
690                git_oid: Some(target.as_bytes().to_vec()),
691                spool_id: None,
692                spool_state_id: None,
693            },
694            TreeEntryTarget::Spoollink { spool_id, state_id } => Self {
695                name: entry.name.clone(),
696                kind: ENTRY_KIND_SPOOLLINK,
697                hash: None,
698                executable: None,
699                git_format: None,
700                git_oid: None,
701                spool_id: Some(spool_id.clone()),
702                spool_state_id: Some(*state_id),
703            },
704        }
705    }
706}
707
708impl TryFrom<EncodedTreeV2> for Tree {
709    type Error = TreeError;
710
711    fn try_from(encoded: EncodedTreeV2) -> Result<Self, Self::Error> {
712        if encoded.version != TREE_FORMAT_VERSION {
713            return Err(TreeError::InvalidStructure(format!(
714                "unsupported tree format version {}; this binary writes {}",
715                encoded.version, TREE_FORMAT_VERSION
716            )));
717        }
718        let mut entries = Vec::with_capacity(encoded.entries.len());
719        for entry in encoded.entries {
720            entries.push(TreeEntry::try_from(entry)?);
721        }
722        Tree::try_from_decoded_entries(entries)
723    }
724}
725
726impl Tree {
727    pub fn decode_current_msgpack(data: &[u8]) -> Result<Self, TreeDecodeError> {
728        let encoded: EncodedTreeV2 = rmp_serde::from_slice(data)?;
729        Ok(Tree::try_from(encoded)?)
730    }
731}
732
733impl TryFrom<EncodedTreeEntryV2> for TreeEntry {
734    type Error = TreeError;
735
736    fn try_from(encoded: EncodedTreeEntryV2) -> Result<Self, Self::Error> {
737        match encoded.kind {
738            ENTRY_KIND_BLOB => TreeEntry::file(
739                encoded.name,
740                required_hash(encoded.hash, ENTRY_KIND_BLOB)?,
741                encoded.executable.unwrap_or(false),
742            ),
743            ENTRY_KIND_TREE => {
744                TreeEntry::directory(encoded.name, required_hash(encoded.hash, ENTRY_KIND_TREE)?)
745            }
746            ENTRY_KIND_SYMLINK => TreeEntry::symlink(
747                encoded.name,
748                required_hash(encoded.hash, ENTRY_KIND_SYMLINK)?,
749            ),
750            ENTRY_KIND_GITLINK => {
751                let format = git_format_from_tag(required_git_format(
752                    encoded.git_format,
753                    ENTRY_KIND_GITLINK,
754                )?)?;
755                let oid = encoded.git_oid.ok_or_else(|| {
756                    TreeError::InvalidStructure("gitlink entry is missing git_oid".into())
757                })?;
758                let target = GitObjectId::from_raw(format, &oid).map_err(|err| {
759                    TreeError::InvalidStructure(format!("invalid gitlink target: {err}"))
760                })?;
761                TreeEntry::gitlink(encoded.name, target)
762            }
763            ENTRY_KIND_SPOOLLINK => {
764                let spool_id = encoded.spool_id.ok_or_else(|| {
765                    TreeError::InvalidStructure("spoollink entry is missing spool_id".into())
766                })?;
767                let state_id = encoded.spool_state_id.ok_or_else(|| {
768                    TreeError::InvalidStructure("spoollink entry is missing spool_state_id".into())
769                })?;
770                TreeEntry::spoollink(encoded.name, spool_id, state_id)
771            }
772            other => Err(TreeError::InvalidStructure(format!(
773                "unknown tree entry kind {other}"
774            ))),
775        }
776    }
777}
778
779fn required_hash(hash: Option<ContentHash>, kind: u8) -> Result<ContentHash, TreeError> {
780    hash.ok_or_else(|| TreeError::InvalidStructure(format!("entry kind {kind} is missing hash")))
781}
782
783fn required_git_format(format: Option<u8>, kind: u8) -> Result<u8, TreeError> {
784    format.ok_or_else(|| {
785        TreeError::InvalidStructure(format!("entry kind {kind} is missing git_format"))
786    })
787}
788
789pub(crate) fn git_format_to_tag(format: GitObjectFormat) -> u8 {
790    match format {
791        GitObjectFormat::Sha1 => GIT_OBJECT_FORMAT_SHA1,
792        GitObjectFormat::Sha256 => GIT_OBJECT_FORMAT_SHA256,
793    }
794}
795
796pub(crate) fn git_format_from_tag(tag: u8) -> Result<GitObjectFormat, TreeError> {
797    match tag {
798        GIT_OBJECT_FORMAT_SHA1 => Ok(GitObjectFormat::Sha1),
799        GIT_OBJECT_FORMAT_SHA256 => Ok(GitObjectFormat::Sha256),
800        other => Err(TreeError::InvalidStructure(format!(
801            "unknown git object format tag {other}"
802        ))),
803    }
804}
805
806impl Default for Tree {
807    fn default() -> Self {
808        Self::new()
809    }
810}
811
812impl IntoIterator for Tree {
813    type Item = TreeEntry;
814    type IntoIter = std::vec::IntoIter<TreeEntry>;
815
816    fn into_iter(self) -> Self::IntoIter {
817        Arc::try_unwrap(self.entries)
818            .unwrap_or_else(|entries| (*entries).clone())
819            .into_iter()
820    }
821}
822
823impl<'a> IntoIterator for &'a Tree {
824    type Item = &'a TreeEntry;
825    type IntoIter = std::slice::Iter<'a, TreeEntry>;
826
827    fn into_iter(self) -> Self::IntoIter {
828        self.entries.iter()
829    }
830}
831
832#[cfg(test)]
833mod spoollink_tests {
834    use super::*;
835
836    #[test]
837    fn spoollink_entry_shape() {
838        let spool_id = SpoolId::parse("acme/child").unwrap();
839        let state_id = StateId::from_bytes([9u8; 32]);
840        let entry = TreeEntry::spoollink("child", spool_id.clone(), state_id).unwrap();
841
842        assert!(entry.is_spoollink());
843        assert_eq!(entry.entry_type(), EntryType::Spoollink);
844        assert_eq!(entry.mode(), FileMode::Spoollink);
845        // Native edge carries no Heddle content hash and no git OID.
846        assert_eq!(entry.content_hash(), None);
847        assert_eq!(entry.leaf_content_hash(), None);
848        assert_eq!(entry.gitlink_target(), None);
849        assert_eq!(entry.spoollink_target(), Some((&spool_id, state_id)));
850    }
851
852    #[test]
853    fn spoollink_roundtrips_through_encoded_tree_v2() {
854        let spool_id = SpoolId::parse("acme/child").unwrap();
855        let state_id = StateId::from_bytes([2u8; 32]);
856
857        // Mix a spoollink alongside the existing kinds so the round-trip also
858        // proves existing entries are undisturbed.
859        let blob_hash = ContentHash::compute(b"hello");
860        let tree = Tree::from_entries(vec![
861            TreeEntry::file("a_blob", blob_hash, false).unwrap(),
862            TreeEntry::spoollink("z_child", spool_id.clone(), state_id).unwrap(),
863        ]);
864
865        let bytes = rmp_serde::to_vec(&tree).unwrap();
866        let decoded = Tree::decode_current_msgpack(&bytes).unwrap();
867
868        assert_eq!(decoded, tree, "tree round-trip must be lossless");
869
870        let child = decoded
871            .get("z_child")
872            .expect("spoollink survives round-trip");
873        assert_eq!(child.spoollink_target(), Some((&spool_id, state_id)));
874        assert_eq!(child.entry_type(), EntryType::Spoollink);
875
876        // Hash is stable and distinct from a same-name gitlink/blob shape.
877        assert_eq!(decoded.hash(), tree.hash());
878    }
879
880    #[test]
881    fn file_mode_spoollink_has_no_git_mode() {
882        // The whole point of a dedicated kind: it must NOT masquerade as a
883        // git submodule (160000) or any other real git mode.
884        assert_eq!(FileMode::Spoollink.to_unix_mode(), 0);
885        assert_ne!(FileMode::Spoollink.to_unix_mode(), 0o160000);
886        assert_eq!(
887            FileMode::from_byte(FileMode::Spoollink.to_byte()),
888            Some(FileMode::Spoollink)
889        );
890        assert_eq!(
891            EntryType::from_byte(EntryType::Spoollink.to_byte()),
892            Some(EntryType::Spoollink)
893        );
894    }
895}
896
897#[cfg(test)]
898mod cow_tests {
899    use super::*;
900
901    fn fixture() -> Tree {
902        Tree::from_entries(vec![
903            TreeEntry::file("a", ContentHash::compute(b"a"), false).unwrap(),
904            TreeEntry::file("b", ContentHash::compute(b"b"), true).unwrap(),
905        ])
906    }
907
908    #[test]
909    fn clone_shares_entries_until_mutated() {
910        let original = fixture();
911        let mut clone = original.clone();
912        assert!(Arc::ptr_eq(&original.entries, &clone.entries));
913
914        clone.insert(TreeEntry::file("c", ContentHash::compute(b"c"), false).unwrap());
915
916        assert!(!Arc::ptr_eq(&original.entries, &clone.entries));
917        assert!(original.get("c").is_none());
918        assert!(clone.get("c").is_some());
919    }
920
921    #[test]
922    fn clone_mutation_preserves_original_hash_and_encoding() {
923        let original = fixture();
924        let original_hash = original.hash();
925        let original_bytes = rmp_serde::to_vec_named(&original).unwrap();
926        let mut clone = original.clone();
927
928        assert!(clone.remove("a").is_some());
929
930        assert_eq!(original.hash(), original_hash);
931        assert_eq!(rmp_serde::to_vec_named(&original).unwrap(), original_bytes);
932        assert_ne!(clone.hash(), original_hash);
933    }
934
935    #[test]
936    fn clone_and_mutate_roundtrips_through_durable_encoding() {
937        let mut tree = fixture().clone();
938        tree.insert(TreeEntry::directory("dir", ContentHash::compute(b"dir")).unwrap());
939        let encoded = rmp_serde::to_vec_named(&tree).unwrap();
940        let decoded: Tree = rmp_serde::from_slice(&encoded).unwrap();
941
942        assert_eq!(decoded, tree);
943        assert_eq!(decoded.hash(), tree.hash());
944    }
945}