Skip to main content

gravityfile_core/
node.rs

1//! File and directory node types.
2
3use std::fmt;
4use std::time::SystemTime;
5
6use compact_str::CompactString;
7use serde::{Deserialize, Serialize};
8
9/// Git status for a file or directory.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
11pub enum GitStatus {
12    /// File/directory has been modified.
13    Modified,
14    /// File/directory is staged for commit.
15    Staged,
16    /// File/directory is not tracked by git.
17    Untracked,
18    /// File/directory is ignored by git.
19    Ignored,
20    /// File/directory has merge conflicts.
21    Conflict,
22    /// File/directory is clean (no changes).
23    #[default]
24    Clean,
25}
26
27impl GitStatus {
28    /// Get a single character indicator for display.
29    #[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    /// Check if this status should be displayed (not clean).
42    #[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/// BLAKE3 content hash for duplicate detection.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct ContentHash([u8; 32]);
64
65impl ContentHash {
66    /// Create a new ContentHash from raw bytes.
67    #[inline]
68    pub fn new(bytes: [u8; 32]) -> Self {
69        Self(bytes)
70    }
71
72    /// Get the raw hash bytes.
73    #[inline]
74    pub fn as_bytes(&self) -> &[u8; 32] {
75        &self.0
76    }
77
78    /// Get the hash as a hex string.
79    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/// Inode information for hardlink detection.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct InodeInfo {
101    /// Inode number.
102    pub inode: u64,
103    /// Device ID.
104    pub device: u64,
105}
106
107impl InodeInfo {
108    /// Create new inode info.
109    #[inline]
110    pub fn new(inode: u64, device: u64) -> Self {
111        Self { inode, device }
112    }
113}
114
115/// Node timestamps retained in the tree. Access and creation times are not
116/// kept per node; read them from the filesystem when a single item needs them.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Timestamps {
119    /// Last modification time.
120    pub modified: SystemTime,
121}
122
123impl Timestamps {
124    #[inline]
125    pub fn with_modified(modified: SystemTime) -> Self {
126        Self { modified }
127    }
128}
129
130/// Type of file system node.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum NodeKind {
133    /// Regular file.
134    File {
135        /// Whether the file is executable.
136        executable: bool,
137    },
138    /// Directory.
139    Directory {
140        /// Total number of files in this subtree.
141        file_count: u64,
142        /// Total number of directories in this subtree.
143        dir_count: u64,
144    },
145    /// Symbolic link.
146    Symlink {
147        /// Link target path (lossy display form).
148        target: Box<str>,
149        /// Whether the link target exists.
150        broken: bool,
151    },
152    /// Other file types (sockets, devices, etc.).
153    Other,
154}
155
156impl NodeKind {
157    /// Check if this is a directory.
158    #[inline]
159    pub fn is_dir(&self) -> bool {
160        matches!(self, NodeKind::Directory { .. })
161    }
162
163    /// Check if this is a regular file.
164    #[inline]
165    pub fn is_file(&self) -> bool {
166        matches!(self, NodeKind::File { .. })
167    }
168
169    /// Check if this is a symlink.
170    #[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/// A single file or directory in the tree.
190///
191/// Debug formatting includes this node's metadata and direct child count, not
192/// descendant contents, so logging a node does not walk the entire tree.
193#[derive(Serialize, Deserialize)]
194pub struct FileNode {
195    /// File/directory name (not full path).
196    pub name: CompactString,
197
198    /// Exact platform filename when the display name cannot represent it.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub native_name: Option<Box<std::ffi::OsString>>,
201
202    /// Node type and associated metadata.
203    pub kind: NodeKind,
204
205    /// Size in bytes: disk usage, or apparent length when the scan used
206    /// apparent sizes. Directories aggregate their contents and their own
207    /// allocation; additional hard links to a counted file contribute zero.
208    /// Derived from `len` and `blocks`; see [`FileNode::remeasure`].
209    pub size: u64,
210
211    /// Apparent length of this entry itself (`st_size`), not aggregated.
212    #[serde(default)]
213    pub len: u64,
214
215    /// 512-byte blocks allocated to this entry itself (not aggregated).
216    pub blocks: u64,
217
218    /// File metadata timestamps.
219    pub timestamps: Timestamps,
220
221    /// Identity of multiply-linked files, for hard-link aware analysis.
222    pub inode: Option<InodeInfo>,
223
224    /// Git status for this file/directory.
225    #[serde(default)]
226    pub git_status: Option<GitStatus>,
227
228    /// Children nodes (directories only), sorted by size descending.
229    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        // Each drained node reaches its destructor with no children. Keep the
255        // remaining siblings in their original allocation, one iterator per depth.
256        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            // Exhaustive destructuring makes added metadata fields require a
273            // deliberate cloning decision instead of silently losing them.
274            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    /// Compare compatibility labels with exact native identity as a tiebreak.
316    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    /// Terminal-safe filename label that preserves visible byte distinctions.
322    pub fn display_name(&self) -> std::borrow::Cow<'_, str> {
323        crate::escape_filename(self.file_name())
324    }
325    /// Filename component for filesystem access; `name` is display text only.
326    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    /// Preserve an exact component while retaining a Unicode display label.
334    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    /// Create a new file node.
342    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    /// Create a new, empty directory node.
359    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    /// Create a symbolic link node.
368    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    /// A childless node whose apparent length is `size`.
382    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    /// Check if this node is a directory.
404    #[inline]
405    pub fn is_dir(&self) -> bool {
406        self.kind.is_dir()
407    }
408
409    /// Check if this node is a file.
410    #[inline]
411    pub fn is_file(&self) -> bool {
412        self.kind.is_file()
413    }
414
415    /// Get the number of direct children.
416    #[inline]
417    pub fn child_count(&self) -> usize {
418        self.children.len()
419    }
420
421    /// Get file count for directories (includes files, symlinks, and other entries),
422    /// 1 for files/symlinks/other.
423    #[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    /// Get directory count for directories.
432    #[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    /// Sort children by size descending, breaking ties by name and native name.
441    ///
442    /// Uses stored sizes without recomputing them. Traversal retains one child
443    /// iterator per directory depth instead of using recursive calls.
444    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    /// Update directory file/dir counts with an iterative post-order traversal.
463    ///
464    /// Retains one traversal frame per directory depth. Symlinks and Other
465    /// entries are counted in the file count; non-directory children are leaves.
466    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            // End the child borrow before propagating its completed totals.
489            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    /// Update counts and sort children in the correct order.
500    pub fn finalize(&mut self) {
501        self.update_counts();
502        self.sort_children_by_size();
503    }
504
505    /// This entry's own size under `apparent`: its length, or its allocated
506    /// blocks. Symbolic links count as zero, as in a scan.
507    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    /// Recompute every `size` below and including this node from the entries'
516    /// own lengths and blocks, then re-sort children by size.
517    ///
518    /// A multiply linked file (one with an `inode`) counts once, at its first
519    /// occurrence in the current child order, unless `count_hard_links`.
520    /// Iterative, so deep trees cannot exhaust the stack.
521    pub fn remeasure(&mut self, apparent: bool, count_hard_links: bool) {
522        let mut seen = std::collections::HashSet::new();
523        // Directories cannot be hard links; only other entries count once.
524        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        // A directory's children are taken out while they are measured and
530        // put back, sorted, once all of them are done.
531        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        // Every scanned entry is a node; growth here multiplies across
584        // millions of files.
585        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        // Test Display impl
664        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                // Both formatting modes must stay independent of descendant
722                // count and depth, even on a stack too small for recursive Debug.
723                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        // Inspect ordering while draining the fixture.
797        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        // Single call should recursively update everything
827        root.update_counts();
828
829        assert_eq!(root.file_count(), 2); // f1 + f2
830        assert_eq!(root.dir_count(), 2); // dir1 + dir2
831    }
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); // file + symlink
858    }
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        // Same size => sorted by name ascending
870        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}