1#![allow(clippy::must_use_candidate)]
20#![allow(clippy::missing_panics_doc)]
21#![allow(clippy::missing_errors_doc)]
22#![allow(clippy::cast_precision_loss)]
23#![allow(clippy::use_self)]
24#![allow(clippy::format_push_string)]
25#![allow(clippy::missing_const_for_fn)]
26#![allow(clippy::needless_continue)]
27#![allow(clippy::map_unwrap_or)]
28
29use crate::dev_server::get_mime_type;
30use glob::Pattern;
31use std::path::{Path, PathBuf};
32
33#[derive(Debug, Clone)]
35pub struct FileNode {
36 pub name: String,
38 pub path: PathBuf,
40 pub size: u64,
42 pub mime_type: String,
44 pub is_dir: bool,
46 pub children: Vec<FileNode>,
48}
49
50impl FileNode {
51 #[must_use]
53 pub fn new_file(path: PathBuf, size: u64) -> Self {
54 let name = path
55 .file_name()
56 .map(|n| n.to_string_lossy().to_string())
57 .unwrap_or_default();
58 let mime_type = get_mime_type(&path);
59
60 Self {
61 name,
62 path,
63 size,
64 mime_type,
65 is_dir: false,
66 children: Vec::new(),
67 }
68 }
69
70 #[must_use]
72 pub fn new_dir(path: PathBuf) -> Self {
73 let name = path
74 .file_name()
75 .map(|n| n.to_string_lossy().to_string())
76 .unwrap_or_else(|| path.to_string_lossy().to_string());
77
78 Self {
79 name,
80 path,
81 size: 0,
82 mime_type: String::new(),
83 is_dir: true,
84 children: Vec::new(),
85 }
86 }
87
88 #[must_use]
90 pub fn total_size(&self) -> u64 {
91 if self.is_dir {
92 self.children.iter().map(FileNode::total_size).sum()
93 } else {
94 self.size
95 }
96 }
97
98 #[must_use]
100 pub fn file_count(&self) -> usize {
101 if self.is_dir {
102 self.children.iter().map(FileNode::file_count).sum()
103 } else {
104 1
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct TreeConfig {
112 pub max_depth: Option<usize>,
114 pub filter: Option<Pattern>,
116 pub show_sizes: bool,
118 pub show_mime_types: bool,
120 pub use_colors: bool,
122}
123
124impl Default for TreeConfig {
125 fn default() -> Self {
126 Self {
127 max_depth: None,
128 filter: None,
129 show_sizes: true,
130 show_mime_types: true,
131 use_colors: atty::is(atty::Stream::Stdout),
132 }
133 }
134}
135
136impl TreeConfig {
137 #[must_use]
139 pub fn with_depth(mut self, depth: Option<usize>) -> Self {
140 self.max_depth = depth;
141 self
142 }
143
144 #[must_use]
146 pub fn with_filter(mut self, pattern: Option<&str>) -> Self {
147 self.filter = pattern.and_then(|p| Pattern::new(p).ok());
148 self
149 }
150
151 #[must_use]
153 pub const fn with_sizes(mut self, show: bool) -> Self {
154 self.show_sizes = show;
155 self
156 }
157
158 #[must_use]
160 pub const fn with_mime_types(mut self, show: bool) -> Self {
161 self.show_mime_types = show;
162 self
163 }
164}
165
166pub fn build_tree(root: &Path, config: &TreeConfig) -> Result<FileNode, std::io::Error> {
172 build_tree_recursive(root, config, 0)
173}
174
175fn build_tree_recursive(
176 path: &Path,
177 config: &TreeConfig,
178 current_depth: usize,
179) -> Result<FileNode, std::io::Error> {
180 let metadata = std::fs::metadata(path)?;
181
182 if metadata.is_file() {
183 return Ok(build_file_node(path, metadata.len(), config));
184 }
185
186 let mut node = FileNode::new_dir(path.to_path_buf());
187 if at_depth_limit(config.max_depth, current_depth) {
188 return Ok(node);
189 }
190
191 let entries = sorted_dir_entries(path)?;
192 for entry in entries {
193 let child_path = entry.path();
194 if should_skip_entry(&child_path) {
195 continue;
196 }
197 if let Ok(child) = build_tree_recursive(&child_path, config, current_depth + 1) {
198 if !child.name.is_empty() {
199 node.children.push(child);
200 }
201 }
202 }
203
204 Ok(node)
205}
206
207fn build_file_node(path: &Path, size: u64, config: &TreeConfig) -> FileNode {
209 if let Some(ref pattern) = config.filter {
210 let name = path
211 .file_name()
212 .map(|n| n.to_string_lossy().to_string())
213 .unwrap_or_default();
214 if !pattern.matches(&name) {
215 return FileNode {
216 name: String::new(),
217 path: path.to_path_buf(),
218 size: 0,
219 mime_type: String::new(),
220 is_dir: false,
221 children: Vec::new(),
222 };
223 }
224 }
225 FileNode::new_file(path.to_path_buf(), size)
226}
227
228fn at_depth_limit(max_depth: Option<usize>, current_depth: usize) -> bool {
229 matches!(max_depth, Some(limit) if current_depth >= limit)
230}
231
232fn sorted_dir_entries(path: &Path) -> Result<Vec<std::fs::DirEntry>, std::io::Error> {
234 let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(Result::ok).collect();
235 entries.sort_by(|a, b| {
236 let a_is_dir = a.path().is_dir();
237 let b_is_dir = b.path().is_dir();
238 match (a_is_dir, b_is_dir) {
239 (true, false) => std::cmp::Ordering::Less,
240 (false, true) => std::cmp::Ordering::Greater,
241 _ => a.file_name().cmp(&b.file_name()),
242 }
243 });
244 Ok(entries)
245}
246
247fn should_skip_entry(path: &Path) -> bool {
248 let name = path
249 .file_name()
250 .map(|n| n.to_string_lossy().to_string())
251 .unwrap_or_default();
252 name.starts_with('.') || name == "node_modules" || name == "target"
253}
254
255#[must_use]
257pub fn format_size(bytes: u64) -> String {
258 const KB: u64 = 1024;
259 const MB: u64 = KB * 1024;
260 const GB: u64 = MB * 1024;
261
262 if bytes >= GB {
263 format!("{:.1} GB", bytes as f64 / GB as f64)
264 } else if bytes >= MB {
265 format!("{:.1} MB", bytes as f64 / MB as f64)
266 } else if bytes >= KB {
267 format!("{:.1} KB", bytes as f64 / KB as f64)
268 } else {
269 format!("{bytes} B")
270 }
271}
272
273#[must_use]
275pub fn render_tree(root: &FileNode, config: &TreeConfig) -> String {
276 let mut output = String::new();
277
278 output.push_str(&root.name);
280 output.push_str("/\n");
281
282 render_node_children(&root.children, config, "", &mut output);
284
285 let total_files = root.file_count();
287 let total_size = root.total_size();
288 output.push('\n');
289 output.push_str(&format!(
290 "Total: {} files, {}\n",
291 total_files,
292 format_size(total_size)
293 ));
294
295 output
296}
297
298fn render_node_children(
299 children: &[FileNode],
300 config: &TreeConfig,
301 prefix: &str,
302 output: &mut String,
303) {
304 let len = children.len();
305
306 for (i, child) in children.iter().enumerate() {
307 let is_last = i == len - 1;
308 let connector = if is_last { "└── " } else { "├── " };
309 let child_prefix = if is_last { " " } else { "│ " };
310
311 output.push_str(prefix);
313 output.push_str(connector);
314 output.push_str(&child.name);
315
316 if child.is_dir {
317 output.push('/');
318 } else {
319 if config.show_sizes {
321 output.push_str(&format!(" ({})", format_size(child.size)));
322 }
323 if config.show_mime_types && !child.mime_type.is_empty() {
324 output.push_str(&format!(" [{}]", child.mime_type));
325 }
326 }
327
328 output.push('\n');
329
330 if child.is_dir && !child.children.is_empty() {
332 let new_prefix = format!("{prefix}{child_prefix}");
333 render_node_children(&child.children, config, &new_prefix, output);
334 }
335 }
336}
337
338pub fn display_tree(root: &Path, config: &TreeConfig) -> Result<(), std::io::Error> {
344 let tree = build_tree(root, config)?;
345 let output = render_tree(&tree, config);
346 print!("{output}");
347 Ok(())
348}
349
350#[cfg(test)]
351#[allow(clippy::unwrap_used, clippy::expect_used)]
352mod tests {
353 use super::*;
354 use tempfile::TempDir;
355
356 #[test]
357 fn test_format_size() {
358 assert_eq!(format_size(0), "0 B");
359 assert_eq!(format_size(512), "512 B");
360 assert_eq!(format_size(1024), "1.0 KB");
361 assert_eq!(format_size(1536), "1.5 KB");
362 assert_eq!(format_size(1_048_576), "1.0 MB");
363 assert_eq!(format_size(1_073_741_824), "1.0 GB");
364 }
365
366 #[test]
367 fn test_file_node_new_file() {
368 let node = FileNode::new_file(PathBuf::from("test.html"), 1024);
369 assert_eq!(node.name, "test.html");
370 assert_eq!(node.size, 1024);
371 assert_eq!(node.mime_type, "text/html");
372 assert!(!node.is_dir);
373 }
374
375 #[test]
376 fn test_file_node_new_dir() {
377 let node = FileNode::new_dir(PathBuf::from("src"));
378 assert_eq!(node.name, "src");
379 assert!(node.is_dir);
380 assert!(node.children.is_empty());
381 }
382
383 #[test]
384 fn test_tree_config_default() {
385 let config = TreeConfig::default();
386 assert!(config.max_depth.is_none());
387 assert!(config.filter.is_none());
388 assert!(config.show_sizes);
389 assert!(config.show_mime_types);
390 }
391
392 #[test]
393 fn test_tree_config_builder() {
394 let config = TreeConfig::default()
395 .with_depth(Some(2))
396 .with_filter(Some("*.rs"))
397 .with_sizes(false)
398 .with_mime_types(false);
399
400 assert_eq!(config.max_depth, Some(2));
401 assert!(config.filter.is_some());
402 assert!(!config.show_sizes);
403 assert!(!config.show_mime_types);
404 }
405
406 #[test]
407 fn test_build_tree_simple() {
408 let temp = TempDir::new().unwrap();
409 std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
410 std::fs::write(temp.path().join("style.css"), "body {}").unwrap();
411
412 let config = TreeConfig::default();
413 let tree = build_tree(temp.path(), &config).unwrap();
414
415 assert!(tree.is_dir);
416 assert_eq!(tree.children.len(), 2);
417 }
418
419 #[test]
420 fn test_build_tree_nested() {
421 let temp = TempDir::new().unwrap();
422 let subdir = temp.path().join("pkg");
423 std::fs::create_dir(&subdir).unwrap();
424 std::fs::write(subdir.join("app.js"), "console.log('hi')").unwrap();
425
426 let config = TreeConfig::default();
427 let tree = build_tree(temp.path(), &config).unwrap();
428
429 assert_eq!(tree.children.len(), 1);
430 assert!(tree.children[0].is_dir);
431 assert_eq!(tree.children[0].children.len(), 1);
432 }
433
434 #[test]
435 fn test_build_tree_with_depth_limit() {
436 let temp = TempDir::new().unwrap();
437 let subdir = temp.path().join("deep");
438 std::fs::create_dir(&subdir).unwrap();
439 std::fs::write(subdir.join("file.txt"), "content").unwrap();
440
441 let config = TreeConfig::default().with_depth(Some(0));
442 let tree = build_tree(temp.path(), &config).unwrap();
443
444 assert!(tree.children.is_empty() || tree.children.iter().all(|c| c.children.is_empty()));
446 }
447
448 #[test]
449 fn test_build_tree_with_filter() {
450 let temp = TempDir::new().unwrap();
451 std::fs::write(temp.path().join("app.js"), "js").unwrap();
452 std::fs::write(temp.path().join("style.css"), "css").unwrap();
453 std::fs::write(temp.path().join("index.html"), "html").unwrap();
454
455 let config = TreeConfig::default().with_filter(Some("*.js"));
456 let tree = build_tree(temp.path(), &config).unwrap();
457
458 assert_eq!(tree.file_count(), 1);
460 }
461
462 #[test]
463 fn test_render_tree() {
464 let mut root = FileNode::new_dir(PathBuf::from("project"));
465 root.children.push(FileNode::new_file(
466 PathBuf::from("project/index.html"),
467 1024,
468 ));
469 root.children
470 .push(FileNode::new_file(PathBuf::from("project/app.js"), 2048));
471
472 let config = TreeConfig::default();
473 let output = render_tree(&root, &config);
474
475 assert!(output.contains("project/"));
476 assert!(output.contains("index.html"));
477 assert!(output.contains("app.js"));
478 assert!(output.contains("Total:"));
479 }
480
481 #[test]
482 fn test_file_node_total_size() {
483 let mut root = FileNode::new_dir(PathBuf::from("root"));
484 root.children
485 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
486 root.children
487 .push(FileNode::new_file(PathBuf::from("b.txt"), 200));
488
489 assert_eq!(root.total_size(), 300);
490 }
491
492 #[test]
493 fn test_file_node_file_count() {
494 let mut root = FileNode::new_dir(PathBuf::from("root"));
495 let mut subdir = FileNode::new_dir(PathBuf::from("sub"));
496 subdir
497 .children
498 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
499 root.children.push(subdir);
500 root.children
501 .push(FileNode::new_file(PathBuf::from("b.txt"), 100));
502
503 assert_eq!(root.file_count(), 2);
504 }
505
506 #[test]
509 fn test_file_node_new_file_empty_name() {
510 let node = FileNode::new_file(PathBuf::from("/"), 0);
511 assert_eq!(node.name, "");
512 }
513
514 #[test]
515 fn test_file_node_new_dir_no_filename() {
516 let node = FileNode::new_dir(PathBuf::from("/"));
518 assert_eq!(node.name, "/");
519 }
520
521 #[test]
522 fn test_file_node_total_size_single_file() {
523 let node = FileNode::new_file(PathBuf::from("test.txt"), 500);
524 assert_eq!(node.total_size(), 500);
525 }
526
527 #[test]
528 fn test_file_node_file_count_single_file() {
529 let node = FileNode::new_file(PathBuf::from("test.txt"), 100);
530 assert_eq!(node.file_count(), 1);
531 }
532
533 #[test]
534 fn test_file_node_file_count_empty_dir() {
535 let node = FileNode::new_dir(PathBuf::from("empty"));
536 assert_eq!(node.file_count(), 0);
537 }
538
539 #[test]
540 fn test_tree_config_invalid_filter() {
541 let config = TreeConfig::default().with_filter(Some("[invalid"));
542 assert!(config.filter.is_none());
543 }
544
545 #[test]
546 fn test_tree_config_none_filter() {
547 let config = TreeConfig::default().with_filter(None);
548 assert!(config.filter.is_none());
549 }
550
551 #[test]
552 fn test_render_tree_nested_directories() {
553 let mut root = FileNode::new_dir(PathBuf::from("project"));
554 let mut subdir = FileNode::new_dir(PathBuf::from("project/src"));
555 subdir.children.push(FileNode::new_file(
556 PathBuf::from("project/src/main.rs"),
557 512,
558 ));
559 root.children.push(subdir);
560 root.children
561 .push(FileNode::new_file(PathBuf::from("project/README.md"), 256));
562
563 let config = TreeConfig::default();
564 let output = render_tree(&root, &config);
565
566 assert!(output.contains("src/"));
567 assert!(output.contains("main.rs"));
568 assert!(output.contains("README.md"));
569 assert!(output.contains("│"));
570 }
571
572 #[test]
573 fn test_render_tree_no_sizes() {
574 let mut root = FileNode::new_dir(PathBuf::from("project"));
575 root.children
576 .push(FileNode::new_file(PathBuf::from("project/test.txt"), 1024));
577
578 let config = TreeConfig::default().with_sizes(false);
579 let output = render_tree(&root, &config);
580
581 assert!(output.contains("test.txt"));
582 assert!(!output.contains("(1.0 KB)"));
585 }
586
587 #[test]
588 fn test_render_tree_no_mime_types() {
589 let mut root = FileNode::new_dir(PathBuf::from("project"));
590 root.children
591 .push(FileNode::new_file(PathBuf::from("project/test.html"), 1024));
592
593 let config = TreeConfig::default().with_mime_types(false);
594 let output = render_tree(&root, &config);
595
596 assert!(output.contains("test.html"));
597 assert!(!output.contains("[text/html]"));
598 }
599
600 #[test]
601 fn test_build_tree_hidden_files() {
602 let temp = TempDir::new().unwrap();
603 std::fs::write(temp.path().join(".hidden"), "secret").unwrap();
604 std::fs::write(temp.path().join("visible.txt"), "public").unwrap();
605
606 let config = TreeConfig::default();
607 let tree = build_tree(temp.path(), &config).unwrap();
608
609 assert_eq!(tree.file_count(), 1);
611 assert_eq!(tree.children[0].name, "visible.txt");
612 }
613
614 #[test]
615 fn test_build_tree_ignores_node_modules() {
616 let temp = TempDir::new().unwrap();
617 let nm = temp.path().join("node_modules");
618 std::fs::create_dir(&nm).unwrap();
619 std::fs::write(nm.join("package.json"), "{}").unwrap();
620 std::fs::write(temp.path().join("index.js"), "code").unwrap();
621
622 let config = TreeConfig::default();
623 let tree = build_tree(temp.path(), &config).unwrap();
624
625 assert_eq!(tree.children.len(), 1);
627 assert_eq!(tree.children[0].name, "index.js");
628 }
629
630 #[test]
631 fn test_build_tree_ignores_target() {
632 let temp = TempDir::new().unwrap();
633 let target = temp.path().join("target");
634 std::fs::create_dir(&target).unwrap();
635 std::fs::write(target.join("debug"), "binary").unwrap();
636 std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
637
638 let config = TreeConfig::default();
639 let tree = build_tree(temp.path(), &config).unwrap();
640
641 assert_eq!(tree.children.len(), 1);
643 assert_eq!(tree.children[0].name, "Cargo.toml");
644 }
645
646 #[test]
647 fn test_build_tree_nonexistent_path() {
648 let config = TreeConfig::default();
649 let result = build_tree(Path::new("/nonexistent/path"), &config);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn test_build_tree_file_instead_of_directory() {
655 let temp = TempDir::new().unwrap();
656 let file_path = temp.path().join("file.txt");
657 std::fs::write(&file_path, "content").unwrap();
658
659 let config = TreeConfig::default();
660 let tree = build_tree(&file_path, &config).unwrap();
661
662 assert!(!tree.is_dir);
663 assert_eq!(tree.name, "file.txt");
664 }
665
666 #[test]
667 fn test_render_tree_empty_mime_type() {
668 let mut root = FileNode::new_dir(PathBuf::from("project"));
669 let mut file = FileNode::new_file(PathBuf::from("project/unknown"), 100);
670 file.mime_type = String::new(); root.children.push(file);
672
673 let config = TreeConfig::default().with_mime_types(true);
674 let output = render_tree(&root, &config);
675
676 assert!(output.contains("unknown"));
678 assert!(!output.contains("[]"));
679 }
680
681 #[test]
682 fn test_display_tree() {
683 let temp = TempDir::new().unwrap();
684 std::fs::write(temp.path().join("test.txt"), "content").unwrap();
685
686 let config = TreeConfig::default();
687 let result = display_tree(temp.path(), &config);
688 assert!(result.is_ok());
689 }
690
691 #[test]
692 fn test_display_tree_error() {
693 let config = TreeConfig::default();
694 let result = display_tree(Path::new("/nonexistent/path"), &config);
695 assert!(result.is_err());
696 }
697
698 #[test]
699 fn test_format_size_large_gigabytes() {
700 assert_eq!(format_size(10_737_418_240), "10.0 GB");
701 }
702
703 #[test]
704 fn test_format_size_precise() {
705 assert_eq!(format_size(1_572_864), "1.5 MB");
706 }
707
708 #[test]
709 fn test_tree_directories_sorted_first() {
710 let temp = TempDir::new().unwrap();
711 std::fs::write(temp.path().join("aaa.txt"), "content").unwrap();
712 let dir = temp.path().join("bbb");
713 std::fs::create_dir(&dir).unwrap();
714
715 let config = TreeConfig::default();
716 let tree = build_tree(temp.path(), &config).unwrap();
717
718 assert_eq!(tree.children.len(), 2);
720 assert!(tree.children[0].is_dir);
721 assert!(!tree.children[1].is_dir);
722 }
723
724 #[test]
725 fn test_render_tree_multiple_files_last_item() {
726 let mut root = FileNode::new_dir(PathBuf::from("project"));
727 root.children
728 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
729 root.children
730 .push(FileNode::new_file(PathBuf::from("b.txt"), 100));
731 root.children
732 .push(FileNode::new_file(PathBuf::from("c.txt"), 100));
733
734 let config = TreeConfig::default();
735 let output = render_tree(&root, &config);
736
737 assert!(output.contains("└── c.txt"));
739 assert!(output.contains("├── a.txt"));
741 assert!(output.contains("├── b.txt"));
742 }
743}