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/// Unique identifier for a node within a tree.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct NodeId(u64);
64
65impl NodeId {
66    /// Create a new NodeId from a u64.
67    #[inline]
68    pub fn new(id: u64) -> Self {
69        Self(id)
70    }
71
72    /// Get the inner u64 value.
73    #[inline]
74    pub fn get(self) -> u64 {
75        self.0
76    }
77}
78
79impl fmt::Display for NodeId {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.0)
82    }
83}
84
85/// BLAKE3 content hash for duplicate detection.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub struct ContentHash([u8; 32]);
88
89impl ContentHash {
90    /// Create a new ContentHash from raw bytes.
91    #[inline]
92    pub fn new(bytes: [u8; 32]) -> Self {
93        Self(bytes)
94    }
95
96    /// Get the raw hash bytes.
97    #[inline]
98    pub fn as_bytes(&self) -> &[u8; 32] {
99        &self.0
100    }
101
102    /// Get the hash as a hex string.
103    pub fn to_hex(&self) -> String {
104        use std::fmt::Write;
105        let mut out = String::with_capacity(64);
106        for byte in &self.0 {
107            write!(out, "{byte:02x}").unwrap();
108        }
109        out
110    }
111}
112
113impl fmt::Display for ContentHash {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        for byte in &self.0 {
116            write!(f, "{byte:02x}")?;
117        }
118        Ok(())
119    }
120}
121
122/// Inode information for hardlink detection.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
124pub struct InodeInfo {
125    /// Inode number.
126    pub inode: u64,
127    /// Device ID.
128    pub device: u64,
129}
130
131impl InodeInfo {
132    /// Create new inode info.
133    #[inline]
134    pub fn new(inode: u64, device: u64) -> Self {
135        Self { inode, device }
136    }
137}
138
139/// File metadata timestamps.
140#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
141pub struct Timestamps {
142    /// Last modification time.
143    pub modified: SystemTime,
144    /// Last access time (if available).
145    pub accessed: Option<SystemTime>,
146    /// Creation time (if available, platform-dependent).
147    pub created: Option<SystemTime>,
148}
149
150impl Timestamps {
151    /// Create timestamps with only modified time.
152    #[inline]
153    pub fn with_modified(modified: SystemTime) -> Self {
154        Self {
155            modified,
156            accessed: None,
157            created: None,
158        }
159    }
160
161    /// Create timestamps with all available times.
162    #[inline]
163    pub fn new(
164        modified: SystemTime,
165        accessed: Option<SystemTime>,
166        created: Option<SystemTime>,
167    ) -> Self {
168        Self {
169            modified,
170            accessed,
171            created,
172        }
173    }
174}
175
176/// Type of file system node.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub enum NodeKind {
179    /// Regular file.
180    File {
181        /// Whether the file is executable.
182        executable: bool,
183    },
184    /// Directory.
185    Directory {
186        /// Total number of files in this subtree.
187        file_count: u64,
188        /// Total number of directories in this subtree.
189        dir_count: u64,
190    },
191    /// Symbolic link.
192    Symlink {
193        /// Link target path.
194        target: CompactString,
195        /// Whether the link target exists.
196        broken: bool,
197    },
198    /// Other file types (sockets, devices, etc.).
199    Other,
200}
201
202impl NodeKind {
203    /// Check if this is a directory.
204    #[inline]
205    pub fn is_dir(&self) -> bool {
206        matches!(self, NodeKind::Directory { .. })
207    }
208
209    /// Check if this is a regular file.
210    #[inline]
211    pub fn is_file(&self) -> bool {
212        matches!(self, NodeKind::File { .. })
213    }
214
215    /// Check if this is a symlink.
216    #[inline]
217    pub fn is_symlink(&self) -> bool {
218        matches!(self, NodeKind::Symlink { .. })
219    }
220}
221
222impl fmt::Display for NodeKind {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self {
225            NodeKind::File { executable: true } => f.write_str("Executable"),
226            NodeKind::File { executable: false } => f.write_str("File"),
227            NodeKind::Directory { .. } => f.write_str("Directory"),
228            NodeKind::Symlink { broken: true, .. } => f.write_str("Broken Symlink"),
229            NodeKind::Symlink { .. } => f.write_str("Symlink"),
230            NodeKind::Other => f.write_str("Other"),
231        }
232    }
233}
234
235/// A single file or directory in the tree.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct FileNode {
238    /// Unique identifier for this node.
239    pub id: NodeId,
240
241    /// File/directory name (not full path).
242    pub name: CompactString,
243
244    /// Node type and associated metadata.
245    pub kind: NodeKind,
246
247    /// Size in bytes (aggregate for directories).
248    pub size: u64,
249
250    /// Disk blocks actually used.
251    pub blocks: u64,
252
253    /// File metadata timestamps.
254    pub timestamps: Timestamps,
255
256    /// Inode info for hardlink detection.
257    pub inode: Option<InodeInfo>,
258
259    /// Content hash (computed on demand). Stored inline — 32 bytes, no heap allocation.
260    pub content_hash: Option<ContentHash>,
261
262    /// Git status for this file/directory.
263    #[serde(default)]
264    pub git_status: Option<GitStatus>,
265
266    /// Children nodes (directories only), sorted by size descending.
267    pub children: Vec<FileNode>,
268}
269
270impl FileNode {
271    /// Create a new file node.
272    pub fn new_file(
273        id: NodeId,
274        name: impl Into<CompactString>,
275        size: u64,
276        blocks: u64,
277        timestamps: Timestamps,
278        executable: bool,
279    ) -> Self {
280        Self {
281            id,
282            name: name.into(),
283            kind: NodeKind::File { executable },
284            size,
285            blocks,
286            timestamps,
287            inode: None,
288            content_hash: None,
289            git_status: None,
290            children: Vec::new(),
291        }
292    }
293
294    /// Create a new directory node.
295    pub fn new_directory(
296        id: NodeId,
297        name: impl Into<CompactString>,
298        timestamps: Timestamps,
299    ) -> Self {
300        Self {
301            id,
302            name: name.into(),
303            kind: NodeKind::Directory {
304                file_count: 0,
305                dir_count: 0,
306            },
307            size: 0,
308            blocks: 0,
309            timestamps,
310            inode: None,
311            content_hash: None,
312            git_status: None,
313            children: Vec::new(),
314        }
315    }
316
317    /// Check if this node is a directory.
318    #[inline]
319    pub fn is_dir(&self) -> bool {
320        self.kind.is_dir()
321    }
322
323    /// Check if this node is a file.
324    #[inline]
325    pub fn is_file(&self) -> bool {
326        self.kind.is_file()
327    }
328
329    /// Get the number of direct children.
330    #[inline]
331    pub fn child_count(&self) -> usize {
332        self.children.len()
333    }
334
335    /// Get file count for directories (includes files, symlinks, and other entries),
336    /// 1 for files/symlinks/other.
337    #[inline]
338    pub fn file_count(&self) -> u64 {
339        match &self.kind {
340            NodeKind::Directory { file_count, .. } => *file_count,
341            NodeKind::File { .. } | NodeKind::Symlink { .. } | NodeKind::Other => 1,
342        }
343    }
344
345    /// Get directory count for directories.
346    #[inline]
347    pub fn dir_count(&self) -> u64 {
348        match &self.kind {
349            NodeKind::Directory { dir_count, .. } => *dir_count,
350            _ => 0,
351        }
352    }
353
354    /// Sort children by size in descending order, with deterministic secondary sort by name.
355    ///
356    /// Must be called after [`update_counts`](Self::update_counts) for correct directory sizes.
357    #[inline]
358    pub fn sort_children_by_size(&mut self) {
359        self.children
360            .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.name.cmp(&b.name)));
361        for child in &mut self.children {
362            if child.is_dir() {
363                child.sort_children_by_size();
364            }
365        }
366    }
367
368    /// Recursively update directory file/dir counts based on children (post-order traversal).
369    ///
370    /// This recurses into all children first, then aggregates counts upward.
371    /// Symlinks and Other entries are counted in the file count.
372    pub fn update_counts(&mut self) {
373        if let NodeKind::Directory {
374            ref mut file_count,
375            ref mut dir_count,
376        } = self.kind
377        {
378            // Recurse into children first (post-order)
379            for child in &mut self.children {
380                child.update_counts();
381            }
382
383            *file_count = 0;
384            *dir_count = 0;
385
386            for child in &self.children {
387                match &child.kind {
388                    NodeKind::File { .. } | NodeKind::Symlink { .. } | NodeKind::Other => {
389                        *file_count += 1;
390                    }
391                    NodeKind::Directory {
392                        file_count: fc,
393                        dir_count: dc,
394                    } => {
395                        *file_count += fc;
396                        *dir_count += dc + 1;
397                    }
398                }
399            }
400        }
401    }
402
403    /// Update counts and sort children in the correct order.
404    pub fn finalize(&mut self) {
405        self.update_counts();
406        self.sort_children_by_size();
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_node_id() {
416        let id = NodeId::new(42);
417        assert_eq!(id.get(), 42);
418        assert_eq!(format!("{id}"), "42");
419    }
420
421    #[test]
422    fn test_content_hash_hex() {
423        let hash = ContentHash::new([0xab; 32]);
424        assert_eq!(hash.to_hex().len(), 64);
425        assert!(hash.to_hex().starts_with("abab"));
426        // Test Display impl
427        assert_eq!(format!("{hash}"), hash.to_hex());
428    }
429
430    #[test]
431    fn test_content_hash_as_bytes() {
432        let bytes = [0xcd; 32];
433        let hash = ContentHash::new(bytes);
434        assert_eq!(hash.as_bytes(), &bytes);
435    }
436
437    #[test]
438    fn test_file_node_creation() {
439        let node = FileNode::new_file(
440            NodeId::new(1),
441            "test.txt",
442            1024,
443            2,
444            Timestamps::with_modified(SystemTime::now()),
445            false,
446        );
447        assert!(node.is_file());
448        assert!(!node.is_dir());
449        assert_eq!(node.size, 1024);
450    }
451
452    #[test]
453    fn test_directory_node_creation() {
454        let node = FileNode::new_directory(
455            NodeId::new(1),
456            "test_dir",
457            Timestamps::with_modified(SystemTime::now()),
458        );
459        assert!(node.is_dir());
460        assert!(!node.is_file());
461    }
462
463    #[test]
464    fn test_update_counts_recursive() {
465        let now = SystemTime::now();
466        let mut root =
467            FileNode::new_directory(NodeId::new(1), "root", Timestamps::with_modified(now));
468        let mut dir1 =
469            FileNode::new_directory(NodeId::new(2), "dir1", Timestamps::with_modified(now));
470        let mut dir2 =
471            FileNode::new_directory(NodeId::new(3), "dir2", Timestamps::with_modified(now));
472        let file1 = FileNode::new_file(
473            NodeId::new(4),
474            "f1",
475            100,
476            1,
477            Timestamps::with_modified(now),
478            false,
479        );
480        let file2 = FileNode::new_file(
481            NodeId::new(5),
482            "f2",
483            200,
484            1,
485            Timestamps::with_modified(now),
486            false,
487        );
488
489        dir2.children.push(file2);
490        dir1.children.push(dir2);
491        dir1.children.push(file1);
492        root.children.push(dir1);
493
494        // Single call should recursively update everything
495        root.update_counts();
496
497        assert_eq!(root.file_count(), 2); // f1 + f2
498        assert_eq!(root.dir_count(), 2); // dir1 + dir2
499    }
500
501    #[test]
502    fn test_update_counts_includes_symlinks() {
503        let now = SystemTime::now();
504        let mut root =
505            FileNode::new_directory(NodeId::new(1), "root", Timestamps::with_modified(now));
506        let file = FileNode::new_file(
507            NodeId::new(2),
508            "f1",
509            100,
510            1,
511            Timestamps::with_modified(now),
512            false,
513        );
514        let symlink = FileNode {
515            id: NodeId::new(3),
516            name: "link".into(),
517            kind: NodeKind::Symlink {
518                target: "target".into(),
519                broken: false,
520            },
521            size: 0,
522            blocks: 0,
523            timestamps: Timestamps::with_modified(now),
524            inode: None,
525            content_hash: None,
526            git_status: None,
527            children: Vec::new(),
528        };
529        root.children.push(file);
530        root.children.push(symlink);
531        root.update_counts();
532        assert_eq!(root.file_count(), 2); // file + symlink
533    }
534
535    #[test]
536    fn test_sort_deterministic() {
537        let now = SystemTime::now();
538        let mut root =
539            FileNode::new_directory(NodeId::new(1), "root", Timestamps::with_modified(now));
540        let f1 = FileNode::new_file(
541            NodeId::new(2),
542            "bbb",
543            100,
544            1,
545            Timestamps::with_modified(now),
546            false,
547        );
548        let f2 = FileNode::new_file(
549            NodeId::new(3),
550            "aaa",
551            100,
552            1,
553            Timestamps::with_modified(now),
554            false,
555        );
556        root.children.push(f1);
557        root.children.push(f2);
558        root.sort_children_by_size();
559        // Same size => sorted by name ascending
560        assert_eq!(root.children[0].name.as_str(), "aaa");
561        assert_eq!(root.children[1].name.as_str(), "bbb");
562    }
563
564    #[test]
565    fn test_git_status_display() {
566        assert_eq!(format!("{}", GitStatus::Modified), "Modified");
567        assert_eq!(format!("{}", GitStatus::Clean), "Clean");
568    }
569
570    #[test]
571    fn test_node_kind_display() {
572        assert_eq!(format!("{}", NodeKind::File { executable: false }), "File");
573        assert_eq!(
574            format!("{}", NodeKind::File { executable: true }),
575            "Executable"
576        );
577        assert_eq!(
578            format!(
579                "{}",
580                NodeKind::Directory {
581                    file_count: 0,
582                    dir_count: 0
583                }
584            ),
585            "Directory"
586        );
587    }
588}