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 NodeId(u64);
64
65impl NodeId {
66 #[inline]
68 pub fn new(id: u64) -> Self {
69 Self(id)
70 }
71
72 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub struct ContentHash([u8; 32]);
88
89impl ContentHash {
90 #[inline]
92 pub fn new(bytes: [u8; 32]) -> Self {
93 Self(bytes)
94 }
95
96 #[inline]
98 pub fn as_bytes(&self) -> &[u8; 32] {
99 &self.0
100 }
101
102 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
124pub struct InodeInfo {
125 pub inode: u64,
127 pub device: u64,
129}
130
131impl InodeInfo {
132 #[inline]
134 pub fn new(inode: u64, device: u64) -> Self {
135 Self { inode, device }
136 }
137}
138
139#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
141pub struct Timestamps {
142 pub modified: SystemTime,
144 pub accessed: Option<SystemTime>,
146 pub created: Option<SystemTime>,
148}
149
150impl Timestamps {
151 #[inline]
153 pub fn with_modified(modified: SystemTime) -> Self {
154 Self {
155 modified,
156 accessed: None,
157 created: None,
158 }
159 }
160
161 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
178pub enum NodeKind {
179 File {
181 executable: bool,
183 },
184 Directory {
186 file_count: u64,
188 dir_count: u64,
190 },
191 Symlink {
193 target: CompactString,
195 broken: bool,
197 },
198 Other,
200}
201
202impl NodeKind {
203 #[inline]
205 pub fn is_dir(&self) -> bool {
206 matches!(self, NodeKind::Directory { .. })
207 }
208
209 #[inline]
211 pub fn is_file(&self) -> bool {
212 matches!(self, NodeKind::File { .. })
213 }
214
215 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct FileNode {
238 pub id: NodeId,
240
241 pub name: CompactString,
243
244 pub kind: NodeKind,
246
247 pub size: u64,
249
250 pub blocks: u64,
252
253 pub timestamps: Timestamps,
255
256 pub inode: Option<InodeInfo>,
258
259 pub content_hash: Option<ContentHash>,
261
262 #[serde(default)]
264 pub git_status: Option<GitStatus>,
265
266 pub children: Vec<FileNode>,
268}
269
270impl FileNode {
271 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 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 #[inline]
319 pub fn is_dir(&self) -> bool {
320 self.kind.is_dir()
321 }
322
323 #[inline]
325 pub fn is_file(&self) -> bool {
326 self.kind.is_file()
327 }
328
329 #[inline]
331 pub fn child_count(&self) -> usize {
332 self.children.len()
333 }
334
335 #[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 #[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 #[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 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 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 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 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 root.update_counts();
496
497 assert_eq!(root.file_count(), 2); assert_eq!(root.dir_count(), 2); }
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); }
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 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}