1use std::fmt;
4use std::time::SystemTime;
5
6use compact_str::CompactString;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
11pub enum GitStatus {
12 Modified,
14 Staged,
16 Untracked,
18 Ignored,
20 Conflict,
22 #[default]
24 Clean,
25}
26
27impl GitStatus {
28 #[inline]
30 pub fn indicator(&self) -> &'static str {
31 match self {
32 GitStatus::Modified => "M",
33 GitStatus::Staged => "A",
34 GitStatus::Untracked => "?",
35 GitStatus::Ignored => "!",
36 GitStatus::Conflict => "C",
37 GitStatus::Clean => " ",
38 }
39 }
40
41 #[inline]
43 pub fn is_displayable(&self) -> bool {
44 !matches!(self, GitStatus::Clean)
45 }
46}
47
48impl fmt::Display for GitStatus {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(match self {
51 GitStatus::Modified => "Modified",
52 GitStatus::Staged => "Staged",
53 GitStatus::Untracked => "Untracked",
54 GitStatus::Ignored => "Ignored",
55 GitStatus::Conflict => "Conflict",
56 GitStatus::Clean => "Clean",
57 })
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct ContentHash([u8; 32]);
64
65impl ContentHash {
66 #[inline]
68 pub fn new(bytes: [u8; 32]) -> Self {
69 Self(bytes)
70 }
71
72 #[inline]
74 pub fn as_bytes(&self) -> &[u8; 32] {
75 &self.0
76 }
77
78 pub fn to_hex(&self) -> String {
80 use std::fmt::Write;
81 let mut out = String::with_capacity(64);
82 for byte in &self.0 {
83 write!(out, "{byte:02x}").unwrap();
84 }
85 out
86 }
87}
88
89impl fmt::Display for ContentHash {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 for byte in &self.0 {
92 write!(f, "{byte:02x}")?;
93 }
94 Ok(())
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct InodeInfo {
101 pub inode: u64,
103 pub device: u64,
105}
106
107impl InodeInfo {
108 #[inline]
110 pub fn new(inode: u64, device: u64) -> Self {
111 Self { inode, device }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Timestamps {
119 pub modified: SystemTime,
121}
122
123impl Timestamps {
124 #[inline]
125 pub fn with_modified(modified: SystemTime) -> Self {
126 Self { modified }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum NodeKind {
133 File {
135 executable: bool,
137 },
138 Directory {
140 file_count: u64,
142 dir_count: u64,
144 },
145 Symlink {
147 target: Box<str>,
149 broken: bool,
151 },
152 Other,
154}
155
156impl NodeKind {
157 #[inline]
159 pub fn is_dir(&self) -> bool {
160 matches!(self, NodeKind::Directory { .. })
161 }
162
163 #[inline]
165 pub fn is_file(&self) -> bool {
166 matches!(self, NodeKind::File { .. })
167 }
168
169 #[inline]
171 pub fn is_symlink(&self) -> bool {
172 matches!(self, NodeKind::Symlink { .. })
173 }
174}
175
176impl fmt::Display for NodeKind {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 match self {
179 NodeKind::File { executable: true } => f.write_str("Executable"),
180 NodeKind::File { executable: false } => f.write_str("File"),
181 NodeKind::Directory { .. } => f.write_str("Directory"),
182 NodeKind::Symlink { broken: true, .. } => f.write_str("Broken Symlink"),
183 NodeKind::Symlink { .. } => f.write_str("Symlink"),
184 NodeKind::Other => f.write_str("Other"),
185 }
186 }
187}
188
189#[derive(Serialize, Deserialize)]
194pub struct FileNode {
195 pub name: CompactString,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub native_name: Option<Box<std::ffi::OsString>>,
201
202 pub kind: NodeKind,
204
205 pub size: u64,
210
211 #[serde(default)]
213 pub len: u64,
214
215 pub blocks: u64,
217
218 pub timestamps: Timestamps,
220
221 pub inode: Option<InodeInfo>,
223
224 #[serde(default)]
226 pub git_status: Option<GitStatus>,
227
228 pub children: Vec<FileNode>,
230}
231
232impl fmt::Debug for FileNode {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 f.debug_struct("FileNode")
235 .field("name", &self.name)
236 .field("native_name", &self.native_name)
237 .field("kind", &self.kind)
238 .field("size", &self.size)
239 .field("len", &self.len)
240 .field("blocks", &self.blocks)
241 .field("timestamps", &self.timestamps)
242 .field("inode", &self.inode)
243 .field("git_status", &self.git_status)
244 .field("child_count", &self.children.len())
245 .finish()
246 }
247}
248
249impl Drop for FileNode {
250 fn drop(&mut self) {
251 if self.children.is_empty() {
252 return;
253 }
254 let mut stack = vec![std::mem::take(&mut self.children).into_iter()];
257 while let Some(children) = stack.last_mut() {
258 let Some(mut child) = children.next() else {
259 stack.pop();
260 continue;
261 };
262 if !child.children.is_empty() {
263 stack.push(std::mem::take(&mut child.children).into_iter());
264 }
265 }
266 }
267}
268
269impl Clone for FileNode {
270 fn clone(&self) -> Self {
271 fn shallow(node: &FileNode) -> FileNode {
272 let FileNode {
275 name,
276 native_name,
277 kind,
278 size,
279 len,
280 blocks,
281 timestamps,
282 inode,
283 git_status,
284 children: _,
285 } = node;
286 FileNode {
287 name: name.clone(),
288 native_name: native_name.clone(),
289 kind: kind.clone(),
290 size: *size,
291 len: *len,
292 blocks: *blocks,
293 timestamps: *timestamps,
294 inode: *inode,
295 git_status: *git_status,
296 children: Vec::new(),
297 }
298 }
299 let mut cloned = shallow(self);
300 cloned.children = self.children.iter().map(shallow).collect();
301 let mut stack = vec![self.children.iter().zip(cloned.children.iter_mut())];
302 while let Some(children) = stack.last_mut() {
303 let Some((source, destination)) = children.next() else {
304 stack.pop();
305 continue;
306 };
307 destination.children = source.children.iter().map(shallow).collect();
308 stack.push(source.children.iter().zip(destination.children.iter_mut()));
309 }
310 cloned
311 }
312}
313
314impl FileNode {
315 pub fn compare_name(&self, other: &Self) -> std::cmp::Ordering {
317 self.name
318 .cmp(&other.name)
319 .then_with(|| self.file_name().cmp(other.file_name()))
320 }
321 pub fn display_name(&self) -> std::borrow::Cow<'_, str> {
323 crate::escape_filename(self.file_name())
324 }
325 pub fn file_name(&self) -> &std::ffi::OsStr {
327 self.native_name
328 .as_ref()
329 .map(|name| name.as_os_str())
330 .unwrap_or_else(|| std::ffi::OsStr::new(self.name.as_str()))
331 }
332
333 pub fn set_file_name(&mut self, name: &std::ffi::OsStr) {
335 self.name = CompactString::new(name.to_string_lossy());
336 self.native_name = name
337 .to_str()
338 .is_none()
339 .then(|| Box::new(name.to_os_string()));
340 }
341 pub fn new_file(
343 name: impl Into<CompactString>,
344 size: u64,
345 blocks: u64,
346 timestamps: Timestamps,
347 executable: bool,
348 ) -> Self {
349 Self::leaf(
350 name,
351 NodeKind::File { executable },
352 size,
353 blocks,
354 timestamps,
355 )
356 }
357
358 pub fn new_directory(name: impl Into<CompactString>, timestamps: Timestamps) -> Self {
360 let kind = NodeKind::Directory {
361 file_count: 0,
362 dir_count: 0,
363 };
364 Self::leaf(name, kind, 0, 0, timestamps)
365 }
366
367 pub fn new_symlink(
369 name: impl Into<CompactString>,
370 target: impl Into<Box<str>>,
371 broken: bool,
372 timestamps: Timestamps,
373 ) -> Self {
374 let kind = NodeKind::Symlink {
375 target: target.into(),
376 broken,
377 };
378 Self::leaf(name, kind, 0, 0, timestamps)
379 }
380
381 fn leaf(
383 name: impl Into<CompactString>,
384 kind: NodeKind,
385 size: u64,
386 blocks: u64,
387 timestamps: Timestamps,
388 ) -> Self {
389 Self {
390 name: name.into(),
391 native_name: None,
392 kind,
393 size,
394 len: size,
395 blocks,
396 timestamps,
397 inode: None,
398 git_status: None,
399 children: Vec::new(),
400 }
401 }
402
403 #[inline]
405 pub fn is_dir(&self) -> bool {
406 self.kind.is_dir()
407 }
408
409 #[inline]
411 pub fn is_file(&self) -> bool {
412 self.kind.is_file()
413 }
414
415 #[inline]
417 pub fn child_count(&self) -> usize {
418 self.children.len()
419 }
420
421 #[inline]
424 pub fn file_count(&self) -> u64 {
425 match &self.kind {
426 NodeKind::Directory { file_count, .. } => *file_count,
427 NodeKind::File { .. } | NodeKind::Symlink { .. } | NodeKind::Other => 1,
428 }
429 }
430
431 #[inline]
433 pub fn dir_count(&self) -> u64 {
434 match &self.kind {
435 NodeKind::Directory { dir_count, .. } => *dir_count,
436 _ => 0,
437 }
438 }
439
440 pub fn sort_children_by_size(&mut self) {
445 self.children
446 .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
447 let mut stack = vec![self.children.iter_mut()];
448 while let Some(children) = stack.last_mut() {
449 let Some(child) = children.next() else {
450 stack.pop();
451 continue;
452 };
453 if child.is_dir() {
454 child
455 .children
456 .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
457 stack.push(child.children.iter_mut());
458 }
459 }
460 }
461
462 pub fn update_counts(&mut self) {
467 let NodeKind::Directory {
468 file_count,
469 dir_count,
470 } = &mut self.kind
471 else {
472 return;
473 };
474 let mut stack = vec![(file_count, dir_count, self.children.iter_mut(), 0u64, 0u64)];
475 while let Some((_, _, children, files, _)) = stack.last_mut() {
476 if let Some(child) = children.next() {
477 if let NodeKind::Directory {
478 file_count,
479 dir_count,
480 } = &mut child.kind
481 {
482 stack.push((file_count, dir_count, child.children.iter_mut(), 0, 0));
483 } else {
484 *files += 1;
485 }
486 continue;
487 }
488 let (file_count, dir_count, _, files, dirs) = stack.pop().expect("active frame");
490 *file_count = files;
491 *dir_count = dirs;
492 if let Some((_, _, _, parent_files, parent_dirs)) = stack.last_mut() {
493 *parent_files += files;
494 *parent_dirs += dirs + 1;
495 }
496 }
497 }
498
499 pub fn finalize(&mut self) {
501 self.update_counts();
502 self.sort_children_by_size();
503 }
504
505 pub fn own_size(&self, apparent: bool) -> u64 {
508 match self.kind {
509 NodeKind::Symlink { .. } => 0,
510 _ if apparent => self.len,
511 _ => self.blocks.saturating_mul(512),
512 }
513 }
514
515 pub fn remeasure(&mut self, apparent: bool, count_hard_links: bool) {
522 let mut seen = std::collections::HashSet::new();
523 let mut counted = |node: &FileNode| match node.inode {
525 Some(identity) if !count_hard_links && !node.is_dir() && !seen.insert(identity) => 0,
526 _ => node.own_size(apparent),
527 };
528 self.size = counted(self);
529 struct Frame {
532 children: Vec<FileNode>,
533 next: usize,
534 total: u64,
535 }
536 let mut stack = vec![Frame {
537 children: std::mem::take(&mut self.children),
538 next: 0,
539 total: 0,
540 }];
541 while let Some(frame) = stack.last_mut() {
542 if let Some(child) = frame.children.get_mut(frame.next) {
543 frame.next += 1;
544 child.size = counted(child);
545 if child.children.is_empty() {
546 frame.total = frame.total.saturating_add(child.size);
547 } else {
548 let children = std::mem::take(&mut child.children);
549 stack.push(Frame {
550 children,
551 next: 0,
552 total: 0,
553 });
554 }
555 continue;
556 }
557 let Frame {
558 mut children,
559 total,
560 ..
561 } = stack.pop().expect("active frame");
562 children.sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
563 let directory = match stack.last_mut() {
564 Some(parent) => &mut parent.children[parent.next - 1],
565 None => &mut *self,
566 };
567 directory.size = directory.size.saturating_add(total);
568 directory.children = children;
569 let size = directory.size;
570 if let Some(parent) = stack.last_mut() {
571 parent.total = parent.total.saturating_add(size);
572 }
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn node_stays_compact() {
583 assert!(
586 std::mem::size_of::<FileNode>() <= 152,
587 "{}",
588 std::mem::size_of::<FileNode>()
589 );
590 assert!(std::mem::size_of::<NodeKind>() <= 24);
591 }
592
593 fn file(name: &str, len: u64, blocks: u64) -> FileNode {
594 FileNode::new_file(
595 name,
596 len,
597 blocks,
598 Timestamps::with_modified(SystemTime::UNIX_EPOCH),
599 false,
600 )
601 }
602
603 fn directory(name: &str, blocks: u64, children: Vec<FileNode>) -> FileNode {
604 let mut node =
605 FileNode::new_directory(name, Timestamps::with_modified(SystemTime::UNIX_EPOCH));
606 node.len = 64;
607 node.blocks = blocks;
608 node.children = children;
609 node
610 }
611
612 #[test]
613 fn remeasure_switches_measures_and_counts_hard_links_once() {
614 let mut linked = file("linked", 1000, 8);
615 linked.inode = Some(InodeInfo::new(7, 1));
616 let mut tree = directory(
617 "root",
618 1,
619 vec![
620 directory("a", 1, vec![linked.clone(), file("small", 10, 1)]),
621 directory("b", 1, vec![linked]),
622 FileNode::new_symlink(
623 "link",
624 "a",
625 false,
626 Timestamps::with_modified(SystemTime::UNIX_EPOCH),
627 ),
628 ],
629 );
630 tree.remeasure(false, false);
631 assert_eq!(tree.size, 512 * (1 + 1 + 8 + 1 + 1));
632 assert_eq!(tree.children[0].name, "a", "the counted copy sorts first");
633 assert_eq!(tree.children[0].size, 512 * 10);
634 assert_eq!(tree.children[1].size, 512);
635 tree.remeasure(true, false);
636 assert_eq!(tree.size, 64 * 3 + 1000 + 10);
637 tree.remeasure(true, true);
638 assert_eq!(tree.size, 64 * 3 + 2000 + 10);
639 }
640
641 #[test]
642 fn remeasure_handles_deep_trees_on_a_small_stack() {
643 std::thread::Builder::new()
644 .stack_size(128 * 1024)
645 .spawn(|| {
646 let mut node = file("leaf", 1, 1);
647 for _ in 0..20_000 {
648 node = directory("d", 1, vec![node]);
649 }
650 node.remeasure(false, false);
651 assert_eq!(node.size, 512 * 20_001);
652 })
653 .unwrap()
654 .join()
655 .unwrap();
656 }
657
658 #[test]
659 fn test_content_hash_hex() {
660 let hash = ContentHash::new([0xab; 32]);
661 assert_eq!(hash.to_hex().len(), 64);
662 assert!(hash.to_hex().starts_with("abab"));
663 assert_eq!(format!("{hash}"), hash.to_hex());
665 }
666
667 #[test]
668 fn test_content_hash_as_bytes() {
669 let bytes = [0xcd; 32];
670 let hash = ContentHash::new(bytes);
671 assert_eq!(hash.as_bytes(), &bytes);
672 }
673
674 #[test]
675 fn test_file_node_creation() {
676 let node = FileNode::new_file(
677 "test.txt",
678 1024,
679 2,
680 Timestamps::with_modified(SystemTime::now()),
681 false,
682 );
683 assert!(node.is_file());
684 assert!(!node.is_dir());
685 assert_eq!(node.size, 1024);
686 }
687
688 #[test]
689 fn test_directory_node_creation() {
690 let node =
691 FileNode::new_directory("test_dir", Timestamps::with_modified(SystemTime::now()));
692 assert!(node.is_dir());
693 assert!(!node.is_file());
694 }
695
696 #[test]
697 fn deep_tree_drops_on_a_small_thread_stack() {
698 std::thread::Builder::new()
699 .stack_size(128 * 1024)
700 .spawn(|| {
701 let depth = 50_000u64;
702 let mut root = FileNode::new_directory(
703 "leaf",
704 Timestamps::with_modified(std::time::UNIX_EPOCH),
705 );
706 for _ in 0..depth {
707 let mut parent = FileNode::new_directory(
708 "d",
709 Timestamps::with_modified(std::time::UNIX_EPOCH),
710 );
711 parent.children.push(root);
712 parent.children.push(FileNode::new_file(
713 "sibling",
714 1,
715 1,
716 Timestamps::with_modified(std::time::UNIX_EPOCH),
717 false,
718 ));
719 root = parent;
720 }
721 for formatted in [format!("{root:?}"), format!("{root:#?}")] {
724 assert!(formatted.contains("child_count: 2"));
725 assert!(!formatted.contains("sibling"));
726 assert!(formatted.len() < 2048);
727 }
728 drop(root);
729 })
730 .unwrap()
731 .join()
732 .unwrap();
733 }
734
735 #[test]
736 fn deep_tree_clone_is_independent_without_recursive_traversal() {
737 let depth = 20_000u64;
738 let mut root =
739 FileNode::new_directory("leaf", Timestamps::with_modified(std::time::UNIX_EPOCH));
740 for _ in 0..depth {
741 let mut parent =
742 FileNode::new_directory("d", Timestamps::with_modified(std::time::UNIX_EPOCH));
743 parent.children.push(root);
744 root = parent;
745 }
746 let mut cloned = root.clone();
747 let mut current = &mut cloned;
748 let mut copied_depth = 0;
749 while !current.children.is_empty() {
750 copied_depth += 1;
751 current = &mut current.children[0];
752 }
753 let copied_name = current.name.clone();
754 current.name = "changed".into();
755 let mut original = &root;
756 while let Some(child) = original.children.first() {
757 original = child;
758 }
759 let original_name = original.name.clone();
760 drop(root);
761 drop(cloned);
762 assert_eq!(copied_depth, depth);
763 assert_eq!(copied_name, "leaf");
764 assert_eq!(
765 original_name, "leaf",
766 "editing the clone left the source alone"
767 );
768 }
769
770 #[test]
771 fn deep_directory_finalization_avoids_recursive_traversal() {
772 let depth = 20_000u64;
773 let mut node =
774 FileNode::new_directory("empty", Timestamps::with_modified(std::time::UNIX_EPOCH));
775 for _ in 0..depth {
776 let mut parent =
777 FileNode::new_directory("d", Timestamps::with_modified(std::time::UNIX_EPOCH));
778 parent.kind = NodeKind::Directory {
779 file_count: u64::MAX,
780 dir_count: u64::MAX,
781 };
782 parent.children.push(node);
783 parent.children.push(FileNode::new_file(
784 "f",
785 1,
786 1,
787 Timestamps::with_modified(std::time::UNIX_EPOCH),
788 false,
789 ));
790 node = parent;
791 }
792 node.finalize();
793 let actual = node.kind.clone();
794 let mut sorted_directories = 0;
795 let mut correctly_sorted = node.children[0].size == 1 && node.children[1].size == 0;
796 let mut pending = std::mem::take(&mut node.children);
798 while let Some(mut child) = pending.pop() {
799 if child.children.len() == 2 {
800 sorted_directories += 1;
801 correctly_sorted &= child.children[0].size == 1 && child.children[1].size == 0;
802 }
803 pending.append(&mut child.children);
804 }
805 assert_eq!(sorted_directories, depth - 1);
806 assert!(correctly_sorted);
807 assert!(
808 matches!(actual, NodeKind::Directory { file_count, dir_count } if file_count == depth && dir_count == depth)
809 );
810 }
811
812 #[test]
813 fn test_update_counts_recursive() {
814 let now = SystemTime::now();
815 let mut root = FileNode::new_directory("root", Timestamps::with_modified(now));
816 let mut dir1 = FileNode::new_directory("dir1", Timestamps::with_modified(now));
817 let mut dir2 = FileNode::new_directory("dir2", Timestamps::with_modified(now));
818 let file1 = FileNode::new_file("f1", 100, 1, Timestamps::with_modified(now), false);
819 let file2 = FileNode::new_file("f2", 200, 1, Timestamps::with_modified(now), false);
820
821 dir2.children.push(file2);
822 dir1.children.push(dir2);
823 dir1.children.push(file1);
824 root.children.push(dir1);
825
826 root.update_counts();
828
829 assert_eq!(root.file_count(), 2); assert_eq!(root.dir_count(), 2); }
832
833 #[test]
834 fn test_update_counts_includes_symlinks() {
835 let now = SystemTime::now();
836 let mut root = FileNode::new_directory("root", Timestamps::with_modified(now));
837 let file = FileNode::new_file("f1", 100, 1, Timestamps::with_modified(now), false);
838 let symlink = FileNode {
839 name: "link".into(),
840 native_name: None,
841 kind: NodeKind::Symlink {
842 target: "target".into(),
843 broken: false,
844 },
845 size: 0,
846 len: 0,
847 blocks: 0,
848 timestamps: Timestamps::with_modified(now),
849 inode: None,
850
851 git_status: None,
852 children: Vec::new(),
853 };
854 root.children.push(file);
855 root.children.push(symlink);
856 root.update_counts();
857 assert_eq!(root.file_count(), 2); }
859
860 #[test]
861 fn test_sort_deterministic() {
862 let now = SystemTime::now();
863 let mut root = FileNode::new_directory("root", Timestamps::with_modified(now));
864 let f1 = FileNode::new_file("bbb", 100, 1, Timestamps::with_modified(now), false);
865 let f2 = FileNode::new_file("aaa", 100, 1, Timestamps::with_modified(now), false);
866 root.children.push(f1);
867 root.children.push(f2);
868 root.sort_children_by_size();
869 assert_eq!(root.children[0].name.as_str(), "aaa");
871 assert_eq!(root.children[1].name.as_str(), "bbb");
872 }
873
874 #[test]
875 fn test_git_status_display() {
876 assert_eq!(format!("{}", GitStatus::Modified), "Modified");
877 assert_eq!(format!("{}", GitStatus::Clean), "Clean");
878 }
879
880 #[test]
881 fn test_node_kind_display() {
882 assert_eq!(format!("{}", NodeKind::File { executable: false }), "File");
883 assert_eq!(
884 format!("{}", NodeKind::File { executable: true }),
885 "Executable"
886 );
887 assert_eq!(
888 format!(
889 "{}",
890 NodeKind::Directory {
891 file_count: 0,
892 dir_count: 0
893 }
894 ),
895 "Directory"
896 );
897 }
898}