1use 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;
16const ENTRY_KIND_SPOOLLINK: u8 = 4;
20const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
21const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
22
23#[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#[repr(u8)]
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum FileMode {
47 Normal,
48 Executable,
49 Symlink,
50 Gitlink,
51 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 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#[repr(u8)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EntryType {
100 Blob,
101 Tree,
102 Symlink,
103 Gitlink,
104 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#[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 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 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
240pub 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 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 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#[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#[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 #[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 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 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 assert_eq!(decoded.hash(), tree.hash());
866 }
867
868 #[test]
869 fn file_mode_spoollink_has_no_git_mode() {
870 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}