Skip to main content

objects/object/
tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree types: entries, structure, and supporting enums.
3
4use std::{fmt, path::Path};
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    Ok(())
263}
264
265#[derive(Clone, Debug, PartialEq, Eq)]
266pub struct TreeEntry {
267    name: String,
268    target: TreeEntryTarget,
269}
270
271impl TreeEntry {
272    #[cfg(test)]
273    pub(crate) fn new_unchecked_for_tests(
274        name: impl Into<String>,
275        target: TreeEntryTarget,
276    ) -> Self {
277        Self {
278            name: name.into(),
279            target,
280        }
281    }
282
283    pub(crate) fn validate(&self) -> Result<(), TreeError> {
284        validate_name(&self.name)
285    }
286
287    pub fn file(
288        name: impl Into<String>,
289        hash: ContentHash,
290        executable: bool,
291    ) -> Result<Self, TreeError> {
292        let name = name.into();
293        validate_name(&name)?;
294        Ok(Self {
295            name,
296            target: TreeEntryTarget::Blob { hash, executable },
297        })
298    }
299
300    pub fn directory(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
301        let name = name.into();
302        validate_name(&name)?;
303        Ok(Self {
304            name,
305            target: TreeEntryTarget::Tree { hash },
306        })
307    }
308
309    pub fn symlink(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
310        let name = name.into();
311        validate_name(&name)?;
312        Ok(Self {
313            name,
314            target: TreeEntryTarget::Symlink { hash },
315        })
316    }
317
318    pub fn gitlink(name: impl Into<String>, target: GitObjectId) -> Result<Self, TreeError> {
319        let name = name.into();
320        validate_name(&name)?;
321        Ok(Self {
322            name,
323            target: TreeEntryTarget::Gitlink { target },
324        })
325    }
326
327    /// Build a native child-spool edge: a pointer to `spool_id` anchored at
328    /// `state_id`. Not a git submodule (see [`TreeEntryTarget::Spoollink`]).
329    pub fn spoollink(
330        name: impl Into<String>,
331        spool_id: SpoolId,
332        state_id: StateId,
333    ) -> Result<Self, TreeError> {
334        let name = name.into();
335        validate_name(&name)?;
336        Ok(Self {
337            name,
338            target: TreeEntryTarget::Spoollink { spool_id, state_id },
339        })
340    }
341
342    pub fn name(&self) -> &str {
343        &self.name
344    }
345
346    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), TreeError> {
347        let name = name.into();
348        validate_name(&name)?;
349        self.name = name;
350        Ok(())
351    }
352
353    pub fn with_mode(&self, mode: FileMode) -> Result<Self, TreeError> {
354        match (&self.target, mode) {
355            (TreeEntryTarget::Blob { hash, .. }, FileMode::Normal | FileMode::Executable) => {
356                Self::file(self.name.clone(), *hash, mode == FileMode::Executable)
357            }
358            (TreeEntryTarget::Symlink { .. }, FileMode::Symlink)
359            | (TreeEntryTarget::Tree { .. }, _)
360            | (TreeEntryTarget::Gitlink { .. }, FileMode::Gitlink)
361            | (TreeEntryTarget::Spoollink { .. }, FileMode::Spoollink)
362                if mode == self.mode() =>
363            {
364                Ok(self.clone())
365            }
366            _ => Err(TreeError::InvalidStructure(format!(
367                "cannot apply mode {:?} to {:?} entry '{}'",
368                mode,
369                self.entry_type(),
370                self.name
371            ))),
372        }
373    }
374
375    pub fn target(&self) -> &TreeEntryTarget {
376        &self.target
377    }
378
379    pub fn entry_type(&self) -> EntryType {
380        self.target.entry_type()
381    }
382
383    pub fn mode(&self) -> FileMode {
384        self.target.mode()
385    }
386
387    pub fn content_hash(&self) -> Option<ContentHash> {
388        self.target.content_hash()
389    }
390
391    pub fn leaf_content_hash(&self) -> Option<ContentHash> {
392        match self.target {
393            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => Some(hash),
394            TreeEntryTarget::Tree { .. }
395            | TreeEntryTarget::Gitlink { .. }
396            | TreeEntryTarget::Spoollink { .. } => None,
397        }
398    }
399
400    pub fn require_content_hash(&self) -> ContentHash {
401        self.content_hash()
402            .expect("tree entry target does not carry a Heddle content hash")
403    }
404
405    pub fn blob_hash(&self) -> Option<ContentHash> {
406        match self.target {
407            TreeEntryTarget::Blob { hash, .. } => Some(hash),
408            _ => None,
409        }
410    }
411
412    pub fn tree_hash(&self) -> Option<ContentHash> {
413        match self.target {
414            TreeEntryTarget::Tree { hash } => Some(hash),
415            _ => None,
416        }
417    }
418
419    pub fn symlink_hash(&self) -> Option<ContentHash> {
420        match self.target {
421            TreeEntryTarget::Symlink { hash } => Some(hash),
422            _ => None,
423        }
424    }
425
426    pub fn gitlink_target(&self) -> Option<GitObjectId> {
427        self.target.gitlink_target()
428    }
429
430    /// The `(spool_id, state_id)` pointer for a spoollink entry, else `None`.
431    pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
432        self.target.spoollink_target()
433    }
434
435    pub fn is_tree(&self) -> bool {
436        self.entry_type() == EntryType::Tree
437    }
438
439    pub fn is_blob(&self) -> bool {
440        self.entry_type() == EntryType::Blob
441    }
442
443    pub fn is_symlink(&self) -> bool {
444        self.entry_type() == EntryType::Symlink
445    }
446
447    pub fn is_gitlink(&self) -> bool {
448        self.entry_type() == EntryType::Gitlink
449    }
450
451    pub fn is_spoollink(&self) -> bool {
452        self.entry_type() == EntryType::Spoollink
453    }
454
455    pub fn is_executable(&self) -> bool {
456        self.mode() == FileMode::Executable
457    }
458
459    pub(crate) fn encoded_len(&self) -> usize {
460        1 + 1 + self.target.encoded_payload_len() + self.name.len() + 1
461    }
462
463    pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
464        self.target.update_hasher(hasher);
465        hasher.update(self.name.as_bytes());
466        hasher.update(&[0]);
467    }
468}
469
470// ── Tree ────────────────────────────────────────────────────────────
471
472#[derive(Clone, Debug, PartialEq, Eq)]
473pub struct Tree {
474    entries: Vec<TreeEntry>,
475}
476
477impl Tree {
478    pub fn new() -> Self {
479        Self {
480            entries: Vec::new(),
481        }
482    }
483
484    pub fn from_entries(mut entries: Vec<TreeEntry>) -> Self {
485        entries.sort_by(|a, b| a.name.cmp(&b.name));
486        Self { entries }
487    }
488
489    #[cfg(test)]
490    pub(crate) fn from_entries_unchecked_for_tests(entries: Vec<TreeEntry>) -> Self {
491        Self { entries }
492    }
493
494    pub fn validate(&self) -> Result<(), TreeError> {
495        let mut previous_name: Option<&str> = None;
496        for entry in &self.entries {
497            entry.validate()?;
498            if let Some(previous) = previous_name
499                && previous >= entry.name.as_str()
500            {
501                return Err(TreeError::InvalidStructure(
502                    "entries must be strictly sorted by name".to_string(),
503                ));
504            }
505            previous_name = Some(&entry.name);
506        }
507        Ok(())
508    }
509
510    pub fn entries(&self) -> &[TreeEntry] {
511        &self.entries
512    }
513
514    pub fn get(&self, name: &str) -> Option<&TreeEntry> {
515        let index = self
516            .entries
517            .binary_search_by(|entry| entry.name.as_str().cmp(name))
518            .ok()?;
519        self.entries.get(index)
520    }
521
522    pub fn insert(&mut self, entry: TreeEntry) {
523        self.entries.retain(|e| e.name != entry.name);
524        let pos = self
525            .entries
526            .iter()
527            .position(|e| e.name > entry.name)
528            .unwrap_or(self.entries.len());
529        self.entries.insert(pos, entry);
530    }
531
532    pub fn remove(&mut self, name: &str) -> Option<TreeEntry> {
533        let pos = self.entries.iter().position(|e| e.name == name)?;
534        Some(self.entries.remove(pos))
535    }
536
537    pub fn is_empty(&self) -> bool {
538        self.entries.is_empty()
539    }
540
541    pub fn len(&self) -> usize {
542        self.entries.len()
543    }
544
545    pub fn hash(&self) -> ContentHash {
546        let total_len: usize = self.entries.iter().map(TreeEntry::encoded_len).sum();
547        ContentHash::compute_typed_with_len("tree", total_len as u64, |hasher| {
548            for entry in &self.entries {
549                entry.update_hasher(hasher);
550            }
551        })
552    }
553
554    pub fn iter(&self) -> impl Iterator<Item = &TreeEntry> {
555        self.entries.iter()
556    }
557
558    pub fn get_path(&self, path: &Path) -> Option<&TreeEntry> {
559        let name = path.file_name()?.to_str()?;
560        if path.parent().is_none_or(|p| p.as_os_str().is_empty()) {
561            self.get(name)
562        } else {
563            None
564        }
565    }
566}
567
568// ── Durable V2 tree encoding ───────────────────────────────────────
569
570#[derive(Serialize, Deserialize)]
571struct EncodedTreeV2 {
572    version: u8,
573    entries: Vec<EncodedTreeEntryV2>,
574}
575
576#[derive(Serialize, Deserialize)]
577struct EncodedTreeEntryV2 {
578    name: String,
579    kind: u8,
580    hash: Option<ContentHash>,
581    executable: Option<bool>,
582    git_format: Option<u8>,
583    git_oid: Option<Vec<u8>>,
584    // Child-spool pointer for SPOOLLINK entries. `default`
585    // keeps the encoding backward-compatible: pre-SPOOLLINK payloads simply
586    // omit these fields.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    spool_id: Option<SpoolId>,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    spool_state_id: Option<StateId>,
591}
592
593impl Serialize for Tree {
594    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
595    where
596        S: Serializer,
597    {
598        EncodedTreeV2::from(self).serialize(serializer)
599    }
600}
601
602impl<'de> Deserialize<'de> for Tree {
603    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
604    where
605        D: Deserializer<'de>,
606    {
607        let encoded = EncodedTreeV2::deserialize(deserializer)?;
608        Tree::try_from(encoded).map_err(de::Error::custom)
609    }
610}
611
612#[derive(Debug)]
613pub(crate) enum TreeDecodeError {
614    Decode(rmp_serde::decode::Error),
615    Invalid(TreeError),
616}
617
618impl From<rmp_serde::decode::Error> for TreeDecodeError {
619    fn from(error: rmp_serde::decode::Error) -> Self {
620        Self::Decode(error)
621    }
622}
623
624impl From<TreeError> for TreeDecodeError {
625    fn from(error: TreeError) -> Self {
626        Self::Invalid(error)
627    }
628}
629
630impl From<&Tree> for EncodedTreeV2 {
631    fn from(tree: &Tree) -> Self {
632        Self {
633            version: TREE_FORMAT_VERSION,
634            entries: tree.entries.iter().map(EncodedTreeEntryV2::from).collect(),
635        }
636    }
637}
638
639impl From<&TreeEntry> for EncodedTreeEntryV2 {
640    fn from(entry: &TreeEntry) -> Self {
641        match entry.target() {
642            TreeEntryTarget::Blob { hash, executable } => Self {
643                name: entry.name.clone(),
644                kind: ENTRY_KIND_BLOB,
645                hash: Some(*hash),
646                executable: Some(*executable),
647                git_format: None,
648                git_oid: None,
649                spool_id: None,
650                spool_state_id: None,
651            },
652            TreeEntryTarget::Tree { hash } => Self {
653                name: entry.name.clone(),
654                kind: ENTRY_KIND_TREE,
655                hash: Some(*hash),
656                executable: None,
657                git_format: None,
658                git_oid: None,
659                spool_id: None,
660                spool_state_id: None,
661            },
662            TreeEntryTarget::Symlink { hash } => Self {
663                name: entry.name.clone(),
664                kind: ENTRY_KIND_SYMLINK,
665                hash: Some(*hash),
666                executable: None,
667                git_format: None,
668                git_oid: None,
669                spool_id: None,
670                spool_state_id: None,
671            },
672            TreeEntryTarget::Gitlink { target } => Self {
673                name: entry.name.clone(),
674                kind: ENTRY_KIND_GITLINK,
675                hash: None,
676                executable: None,
677                git_format: Some(git_format_to_tag(target.format())),
678                git_oid: Some(target.as_bytes().to_vec()),
679                spool_id: None,
680                spool_state_id: None,
681            },
682            TreeEntryTarget::Spoollink { spool_id, state_id } => Self {
683                name: entry.name.clone(),
684                kind: ENTRY_KIND_SPOOLLINK,
685                hash: None,
686                executable: None,
687                git_format: None,
688                git_oid: None,
689                spool_id: Some(spool_id.clone()),
690                spool_state_id: Some(*state_id),
691            },
692        }
693    }
694}
695
696impl TryFrom<EncodedTreeV2> for Tree {
697    type Error = TreeError;
698
699    fn try_from(encoded: EncodedTreeV2) -> Result<Self, Self::Error> {
700        if encoded.version != TREE_FORMAT_VERSION {
701            return Err(TreeError::InvalidStructure(format!(
702                "unsupported tree format version {}; this binary writes {}",
703                encoded.version, TREE_FORMAT_VERSION
704            )));
705        }
706        let mut entries = Vec::with_capacity(encoded.entries.len());
707        for entry in encoded.entries {
708            entries.push(TreeEntry::try_from(entry)?);
709        }
710        let tree = Tree::from_entries(entries);
711        tree.validate()?;
712        Ok(tree)
713    }
714}
715
716impl Tree {
717    pub(crate) fn decode_current_msgpack(data: &[u8]) -> Result<Self, TreeDecodeError> {
718        let encoded: EncodedTreeV2 = rmp_serde::from_slice(data)?;
719        Ok(Tree::try_from(encoded)?)
720    }
721}
722
723impl TryFrom<EncodedTreeEntryV2> for TreeEntry {
724    type Error = TreeError;
725
726    fn try_from(encoded: EncodedTreeEntryV2) -> Result<Self, Self::Error> {
727        match encoded.kind {
728            ENTRY_KIND_BLOB => TreeEntry::file(
729                encoded.name,
730                required_hash(encoded.hash, ENTRY_KIND_BLOB)?,
731                encoded.executable.unwrap_or(false),
732            ),
733            ENTRY_KIND_TREE => {
734                TreeEntry::directory(encoded.name, required_hash(encoded.hash, ENTRY_KIND_TREE)?)
735            }
736            ENTRY_KIND_SYMLINK => TreeEntry::symlink(
737                encoded.name,
738                required_hash(encoded.hash, ENTRY_KIND_SYMLINK)?,
739            ),
740            ENTRY_KIND_GITLINK => {
741                let format = git_format_from_tag(required_git_format(
742                    encoded.git_format,
743                    ENTRY_KIND_GITLINK,
744                )?)?;
745                let oid = encoded.git_oid.ok_or_else(|| {
746                    TreeError::InvalidStructure("gitlink entry is missing git_oid".into())
747                })?;
748                let target = GitObjectId::from_raw(format, &oid).map_err(|err| {
749                    TreeError::InvalidStructure(format!("invalid gitlink target: {err}"))
750                })?;
751                TreeEntry::gitlink(encoded.name, target)
752            }
753            ENTRY_KIND_SPOOLLINK => {
754                let spool_id = encoded.spool_id.ok_or_else(|| {
755                    TreeError::InvalidStructure("spoollink entry is missing spool_id".into())
756                })?;
757                let state_id = encoded.spool_state_id.ok_or_else(|| {
758                    TreeError::InvalidStructure("spoollink entry is missing spool_state_id".into())
759                })?;
760                TreeEntry::spoollink(encoded.name, spool_id, state_id)
761            }
762            other => Err(TreeError::InvalidStructure(format!(
763                "unknown tree entry kind {other}"
764            ))),
765        }
766    }
767}
768
769fn required_hash(hash: Option<ContentHash>, kind: u8) -> Result<ContentHash, TreeError> {
770    hash.ok_or_else(|| TreeError::InvalidStructure(format!("entry kind {kind} is missing hash")))
771}
772
773fn required_git_format(format: Option<u8>, kind: u8) -> Result<u8, TreeError> {
774    format.ok_or_else(|| {
775        TreeError::InvalidStructure(format!("entry kind {kind} is missing git_format"))
776    })
777}
778
779fn git_format_to_tag(format: GitObjectFormat) -> u8 {
780    match format {
781        GitObjectFormat::Sha1 => GIT_OBJECT_FORMAT_SHA1,
782        GitObjectFormat::Sha256 => GIT_OBJECT_FORMAT_SHA256,
783    }
784}
785
786fn git_format_from_tag(tag: u8) -> Result<GitObjectFormat, TreeError> {
787    match tag {
788        GIT_OBJECT_FORMAT_SHA1 => Ok(GitObjectFormat::Sha1),
789        GIT_OBJECT_FORMAT_SHA256 => Ok(GitObjectFormat::Sha256),
790        other => Err(TreeError::InvalidStructure(format!(
791            "unknown git object format tag {other}"
792        ))),
793    }
794}
795
796impl Default for Tree {
797    fn default() -> Self {
798        Self::new()
799    }
800}
801
802impl IntoIterator for Tree {
803    type Item = TreeEntry;
804    type IntoIter = std::vec::IntoIter<TreeEntry>;
805
806    fn into_iter(self) -> Self::IntoIter {
807        self.entries.into_iter()
808    }
809}
810
811impl<'a> IntoIterator for &'a Tree {
812    type Item = &'a TreeEntry;
813    type IntoIter = std::slice::Iter<'a, TreeEntry>;
814
815    fn into_iter(self) -> Self::IntoIter {
816        self.entries.iter()
817    }
818}
819
820#[cfg(test)]
821mod spoollink_tests {
822    use super::*;
823
824    #[test]
825    fn spoollink_entry_shape() {
826        let spool_id = SpoolId::parse("acme/child").unwrap();
827        let state_id = StateId::from_bytes([9u8; 32]);
828        let entry = TreeEntry::spoollink("child", spool_id.clone(), state_id).unwrap();
829
830        assert!(entry.is_spoollink());
831        assert_eq!(entry.entry_type(), EntryType::Spoollink);
832        assert_eq!(entry.mode(), FileMode::Spoollink);
833        // Native edge carries no Heddle content hash and no git OID.
834        assert_eq!(entry.content_hash(), None);
835        assert_eq!(entry.leaf_content_hash(), None);
836        assert_eq!(entry.gitlink_target(), None);
837        assert_eq!(entry.spoollink_target(), Some((&spool_id, state_id)));
838    }
839
840    #[test]
841    fn spoollink_roundtrips_through_encoded_tree_v2() {
842        let spool_id = SpoolId::parse("acme/child").unwrap();
843        let state_id = StateId::from_bytes([2u8; 32]);
844
845        // Mix a spoollink alongside the existing kinds so the round-trip also
846        // proves existing entries are undisturbed.
847        let blob_hash = ContentHash::compute(b"hello");
848        let tree = Tree::from_entries(vec![
849            TreeEntry::file("a_blob", blob_hash, false).unwrap(),
850            TreeEntry::spoollink("z_child", spool_id.clone(), state_id).unwrap(),
851        ]);
852
853        let bytes = rmp_serde::to_vec(&tree).unwrap();
854        let decoded = Tree::decode_current_msgpack(&bytes).unwrap();
855
856        assert_eq!(decoded, tree, "tree round-trip must be lossless");
857
858        let child = decoded
859            .get("z_child")
860            .expect("spoollink survives round-trip");
861        assert_eq!(child.spoollink_target(), Some((&spool_id, state_id)));
862        assert_eq!(child.entry_type(), EntryType::Spoollink);
863
864        // Hash is stable and distinct from a same-name gitlink/blob shape.
865        assert_eq!(decoded.hash(), tree.hash());
866    }
867
868    #[test]
869    fn file_mode_spoollink_has_no_git_mode() {
870        // The whole point of a dedicated kind: it must NOT masquerade as a
871        // git submodule (160000) or any other real git mode.
872        assert_eq!(FileMode::Spoollink.to_unix_mode(), 0);
873        assert_ne!(FileMode::Spoollink.to_unix_mode(), 0o160000);
874        assert_eq!(
875            FileMode::from_byte(FileMode::Spoollink.to_byte()),
876            Some(FileMode::Spoollink)
877        );
878        assert_eq!(
879            EntryType::from_byte(EntryType::Spoollink.to_byte()),
880            Some(EntryType::Spoollink)
881        );
882    }
883}