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