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 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 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 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#[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#[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 #[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 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 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 assert_eq!(decoded.hash(), tree.hash());
850 }
851
852 #[test]
853 fn file_mode_spoollink_has_no_git_mode() {
854 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}