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 if let Some(ref pattern) = config.filter {
185 let name = path
186 .file_name()
187 .map(|n| n.to_string_lossy().to_string())
188 .unwrap_or_default();
189 if !pattern.matches(&name) {
190 return Ok(FileNode {
192 name: String::new(),
193 path: path.to_path_buf(),
194 size: 0,
195 mime_type: String::new(),
196 is_dir: false,
197 children: Vec::new(),
198 });
199 }
200 }
201
202 return Ok(FileNode::new_file(path.to_path_buf(), metadata.len()));
203 }
204
205 let mut node = FileNode::new_dir(path.to_path_buf());
207
208 if let Some(max_depth) = config.max_depth {
210 if current_depth >= max_depth {
211 return Ok(node);
212 }
213 }
214
215 let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(Result::ok).collect();
217
218 entries.sort_by(|a, b| {
220 let a_is_dir = a.path().is_dir();
221 let b_is_dir = b.path().is_dir();
222 match (a_is_dir, b_is_dir) {
223 (true, false) => std::cmp::Ordering::Less,
224 (false, true) => std::cmp::Ordering::Greater,
225 _ => a.file_name().cmp(&b.file_name()),
226 }
227 });
228
229 for entry in entries {
230 let child_path = entry.path();
231
232 let name = child_path
234 .file_name()
235 .map(|n| n.to_string_lossy().to_string())
236 .unwrap_or_default();
237
238 if name.starts_with('.') || name == "node_modules" || name == "target" {
239 continue;
240 }
241
242 match build_tree_recursive(&child_path, config, current_depth + 1) {
243 Ok(child) => {
244 if !child.name.is_empty() {
246 node.children.push(child);
247 }
248 }
249 Err(_) => continue, }
251 }
252
253 Ok(node)
254}
255
256#[must_use]
258pub fn format_size(bytes: u64) -> String {
259 const KB: u64 = 1024;
260 const MB: u64 = KB * 1024;
261 const GB: u64 = MB * 1024;
262
263 if bytes >= GB {
264 format!("{:.1} GB", bytes as f64 / GB as f64)
265 } else if bytes >= MB {
266 format!("{:.1} MB", bytes as f64 / MB as f64)
267 } else if bytes >= KB {
268 format!("{:.1} KB", bytes as f64 / KB as f64)
269 } else {
270 format!("{bytes} B")
271 }
272}
273
274#[must_use]
276pub fn render_tree(root: &FileNode, config: &TreeConfig) -> String {
277 let mut output = String::new();
278
279 output.push_str(&root.name);
281 output.push_str("/\n");
282
283 render_node_children(&root.children, config, "", &mut output);
285
286 let total_files = root.file_count();
288 let total_size = root.total_size();
289 output.push('\n');
290 output.push_str(&format!(
291 "Total: {} files, {}\n",
292 total_files,
293 format_size(total_size)
294 ));
295
296 output
297}
298
299fn render_node_children(
300 children: &[FileNode],
301 config: &TreeConfig,
302 prefix: &str,
303 output: &mut String,
304) {
305 let len = children.len();
306
307 for (i, child) in children.iter().enumerate() {
308 let is_last = i == len - 1;
309 let connector = if is_last { "└── " } else { "├── " };
310 let child_prefix = if is_last { " " } else { "│ " };
311
312 output.push_str(prefix);
314 output.push_str(connector);
315 output.push_str(&child.name);
316
317 if child.is_dir {
318 output.push('/');
319 } else {
320 if config.show_sizes {
322 output.push_str(&format!(" ({})", format_size(child.size)));
323 }
324 if config.show_mime_types && !child.mime_type.is_empty() {
325 output.push_str(&format!(" [{}]", child.mime_type));
326 }
327 }
328
329 output.push('\n');
330
331 if child.is_dir && !child.children.is_empty() {
333 let new_prefix = format!("{prefix}{child_prefix}");
334 render_node_children(&child.children, config, &new_prefix, output);
335 }
336 }
337}
338
339pub fn display_tree(root: &Path, config: &TreeConfig) -> Result<(), std::io::Error> {
345 let tree = build_tree(root, config)?;
346 let output = render_tree(&tree, config);
347 print!("{output}");
348 Ok(())
349}
350
351#[cfg(test)]
352#[allow(clippy::unwrap_used, clippy::expect_used)]
353mod tests {
354 use super::*;
355 use tempfile::TempDir;
356
357 #[test]
358 fn test_format_size() {
359 assert_eq!(format_size(0), "0 B");
360 assert_eq!(format_size(512), "512 B");
361 assert_eq!(format_size(1024), "1.0 KB");
362 assert_eq!(format_size(1536), "1.5 KB");
363 assert_eq!(format_size(1_048_576), "1.0 MB");
364 assert_eq!(format_size(1_073_741_824), "1.0 GB");
365 }
366
367 #[test]
368 fn test_file_node_new_file() {
369 let node = FileNode::new_file(PathBuf::from("test.html"), 1024);
370 assert_eq!(node.name, "test.html");
371 assert_eq!(node.size, 1024);
372 assert_eq!(node.mime_type, "text/html");
373 assert!(!node.is_dir);
374 }
375
376 #[test]
377 fn test_file_node_new_dir() {
378 let node = FileNode::new_dir(PathBuf::from("src"));
379 assert_eq!(node.name, "src");
380 assert!(node.is_dir);
381 assert!(node.children.is_empty());
382 }
383
384 #[test]
385 fn test_tree_config_default() {
386 let config = TreeConfig::default();
387 assert!(config.max_depth.is_none());
388 assert!(config.filter.is_none());
389 assert!(config.show_sizes);
390 assert!(config.show_mime_types);
391 }
392
393 #[test]
394 fn test_tree_config_builder() {
395 let config = TreeConfig::default()
396 .with_depth(Some(2))
397 .with_filter(Some("*.rs"))
398 .with_sizes(false)
399 .with_mime_types(false);
400
401 assert_eq!(config.max_depth, Some(2));
402 assert!(config.filter.is_some());
403 assert!(!config.show_sizes);
404 assert!(!config.show_mime_types);
405 }
406
407 #[test]
408 fn test_build_tree_simple() {
409 let temp = TempDir::new().unwrap();
410 std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
411 std::fs::write(temp.path().join("style.css"), "body {}").unwrap();
412
413 let config = TreeConfig::default();
414 let tree = build_tree(temp.path(), &config).unwrap();
415
416 assert!(tree.is_dir);
417 assert_eq!(tree.children.len(), 2);
418 }
419
420 #[test]
421 fn test_build_tree_nested() {
422 let temp = TempDir::new().unwrap();
423 let subdir = temp.path().join("pkg");
424 std::fs::create_dir(&subdir).unwrap();
425 std::fs::write(subdir.join("app.js"), "console.log('hi')").unwrap();
426
427 let config = TreeConfig::default();
428 let tree = build_tree(temp.path(), &config).unwrap();
429
430 assert_eq!(tree.children.len(), 1);
431 assert!(tree.children[0].is_dir);
432 assert_eq!(tree.children[0].children.len(), 1);
433 }
434
435 #[test]
436 fn test_build_tree_with_depth_limit() {
437 let temp = TempDir::new().unwrap();
438 let subdir = temp.path().join("deep");
439 std::fs::create_dir(&subdir).unwrap();
440 std::fs::write(subdir.join("file.txt"), "content").unwrap();
441
442 let config = TreeConfig::default().with_depth(Some(0));
443 let tree = build_tree(temp.path(), &config).unwrap();
444
445 assert!(tree.children.is_empty() || tree.children.iter().all(|c| c.children.is_empty()));
447 }
448
449 #[test]
450 fn test_build_tree_with_filter() {
451 let temp = TempDir::new().unwrap();
452 std::fs::write(temp.path().join("app.js"), "js").unwrap();
453 std::fs::write(temp.path().join("style.css"), "css").unwrap();
454 std::fs::write(temp.path().join("index.html"), "html").unwrap();
455
456 let config = TreeConfig::default().with_filter(Some("*.js"));
457 let tree = build_tree(temp.path(), &config).unwrap();
458
459 assert_eq!(tree.file_count(), 1);
461 }
462
463 #[test]
464 fn test_render_tree() {
465 let mut root = FileNode::new_dir(PathBuf::from("project"));
466 root.children.push(FileNode::new_file(
467 PathBuf::from("project/index.html"),
468 1024,
469 ));
470 root.children
471 .push(FileNode::new_file(PathBuf::from("project/app.js"), 2048));
472
473 let config = TreeConfig::default();
474 let output = render_tree(&root, &config);
475
476 assert!(output.contains("project/"));
477 assert!(output.contains("index.html"));
478 assert!(output.contains("app.js"));
479 assert!(output.contains("Total:"));
480 }
481
482 #[test]
483 fn test_file_node_total_size() {
484 let mut root = FileNode::new_dir(PathBuf::from("root"));
485 root.children
486 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
487 root.children
488 .push(FileNode::new_file(PathBuf::from("b.txt"), 200));
489
490 assert_eq!(root.total_size(), 300);
491 }
492
493 #[test]
494 fn test_file_node_file_count() {
495 let mut root = FileNode::new_dir(PathBuf::from("root"));
496 let mut subdir = FileNode::new_dir(PathBuf::from("sub"));
497 subdir
498 .children
499 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
500 root.children.push(subdir);
501 root.children
502 .push(FileNode::new_file(PathBuf::from("b.txt"), 100));
503
504 assert_eq!(root.file_count(), 2);
505 }
506
507 #[test]
510 fn test_file_node_new_file_empty_name() {
511 let node = FileNode::new_file(PathBuf::from("/"), 0);
512 assert_eq!(node.name, "");
513 }
514
515 #[test]
516 fn test_file_node_new_dir_no_filename() {
517 let node = FileNode::new_dir(PathBuf::from("/"));
519 assert_eq!(node.name, "/");
520 }
521
522 #[test]
523 fn test_file_node_total_size_single_file() {
524 let node = FileNode::new_file(PathBuf::from("test.txt"), 500);
525 assert_eq!(node.total_size(), 500);
526 }
527
528 #[test]
529 fn test_file_node_file_count_single_file() {
530 let node = FileNode::new_file(PathBuf::from("test.txt"), 100);
531 assert_eq!(node.file_count(), 1);
532 }
533
534 #[test]
535 fn test_file_node_file_count_empty_dir() {
536 let node = FileNode::new_dir(PathBuf::from("empty"));
537 assert_eq!(node.file_count(), 0);
538 }
539
540 #[test]
541 fn test_tree_config_invalid_filter() {
542 let config = TreeConfig::default().with_filter(Some("[invalid"));
543 assert!(config.filter.is_none());
544 }
545
546 #[test]
547 fn test_tree_config_none_filter() {
548 let config = TreeConfig::default().with_filter(None);
549 assert!(config.filter.is_none());
550 }
551
552 #[test]
553 fn test_render_tree_nested_directories() {
554 let mut root = FileNode::new_dir(PathBuf::from("project"));
555 let mut subdir = FileNode::new_dir(PathBuf::from("project/src"));
556 subdir.children.push(FileNode::new_file(
557 PathBuf::from("project/src/main.rs"),
558 512,
559 ));
560 root.children.push(subdir);
561 root.children
562 .push(FileNode::new_file(PathBuf::from("project/README.md"), 256));
563
564 let config = TreeConfig::default();
565 let output = render_tree(&root, &config);
566
567 assert!(output.contains("src/"));
568 assert!(output.contains("main.rs"));
569 assert!(output.contains("README.md"));
570 assert!(output.contains("│"));
571 }
572
573 #[test]
574 fn test_render_tree_no_sizes() {
575 let mut root = FileNode::new_dir(PathBuf::from("project"));
576 root.children
577 .push(FileNode::new_file(PathBuf::from("project/test.txt"), 1024));
578
579 let config = TreeConfig::default().with_sizes(false);
580 let output = render_tree(&root, &config);
581
582 assert!(output.contains("test.txt"));
583 assert!(!output.contains("(1.0 KB)"));
586 }
587
588 #[test]
589 fn test_render_tree_no_mime_types() {
590 let mut root = FileNode::new_dir(PathBuf::from("project"));
591 root.children
592 .push(FileNode::new_file(PathBuf::from("project/test.html"), 1024));
593
594 let config = TreeConfig::default().with_mime_types(false);
595 let output = render_tree(&root, &config);
596
597 assert!(output.contains("test.html"));
598 assert!(!output.contains("[text/html]"));
599 }
600
601 #[test]
602 fn test_build_tree_hidden_files() {
603 let temp = TempDir::new().unwrap();
604 std::fs::write(temp.path().join(".hidden"), "secret").unwrap();
605 std::fs::write(temp.path().join("visible.txt"), "public").unwrap();
606
607 let config = TreeConfig::default();
608 let tree = build_tree(temp.path(), &config).unwrap();
609
610 assert_eq!(tree.file_count(), 1);
612 assert_eq!(tree.children[0].name, "visible.txt");
613 }
614
615 #[test]
616 fn test_build_tree_ignores_node_modules() {
617 let temp = TempDir::new().unwrap();
618 let nm = temp.path().join("node_modules");
619 std::fs::create_dir(&nm).unwrap();
620 std::fs::write(nm.join("package.json"), "{}").unwrap();
621 std::fs::write(temp.path().join("index.js"), "code").unwrap();
622
623 let config = TreeConfig::default();
624 let tree = build_tree(temp.path(), &config).unwrap();
625
626 assert_eq!(tree.children.len(), 1);
628 assert_eq!(tree.children[0].name, "index.js");
629 }
630
631 #[test]
632 fn test_build_tree_ignores_target() {
633 let temp = TempDir::new().unwrap();
634 let target = temp.path().join("target");
635 std::fs::create_dir(&target).unwrap();
636 std::fs::write(target.join("debug"), "binary").unwrap();
637 std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
638
639 let config = TreeConfig::default();
640 let tree = build_tree(temp.path(), &config).unwrap();
641
642 assert_eq!(tree.children.len(), 1);
644 assert_eq!(tree.children[0].name, "Cargo.toml");
645 }
646
647 #[test]
648 fn test_build_tree_nonexistent_path() {
649 let config = TreeConfig::default();
650 let result = build_tree(Path::new("/nonexistent/path"), &config);
651 assert!(result.is_err());
652 }
653
654 #[test]
655 fn test_build_tree_file_instead_of_directory() {
656 let temp = TempDir::new().unwrap();
657 let file_path = temp.path().join("file.txt");
658 std::fs::write(&file_path, "content").unwrap();
659
660 let config = TreeConfig::default();
661 let tree = build_tree(&file_path, &config).unwrap();
662
663 assert!(!tree.is_dir);
664 assert_eq!(tree.name, "file.txt");
665 }
666
667 #[test]
668 fn test_render_tree_empty_mime_type() {
669 let mut root = FileNode::new_dir(PathBuf::from("project"));
670 let mut file = FileNode::new_file(PathBuf::from("project/unknown"), 100);
671 file.mime_type = String::new(); root.children.push(file);
673
674 let config = TreeConfig::default().with_mime_types(true);
675 let output = render_tree(&root, &config);
676
677 assert!(output.contains("unknown"));
679 assert!(!output.contains("[]"));
680 }
681
682 #[test]
683 fn test_display_tree() {
684 let temp = TempDir::new().unwrap();
685 std::fs::write(temp.path().join("test.txt"), "content").unwrap();
686
687 let config = TreeConfig::default();
688 let result = display_tree(temp.path(), &config);
689 assert!(result.is_ok());
690 }
691
692 #[test]
693 fn test_display_tree_error() {
694 let config = TreeConfig::default();
695 let result = display_tree(Path::new("/nonexistent/path"), &config);
696 assert!(result.is_err());
697 }
698
699 #[test]
700 fn test_format_size_large_gigabytes() {
701 assert_eq!(format_size(10_737_418_240), "10.0 GB");
702 }
703
704 #[test]
705 fn test_format_size_precise() {
706 assert_eq!(format_size(1_572_864), "1.5 MB");
707 }
708
709 #[test]
710 fn test_tree_directories_sorted_first() {
711 let temp = TempDir::new().unwrap();
712 std::fs::write(temp.path().join("aaa.txt"), "content").unwrap();
713 let dir = temp.path().join("bbb");
714 std::fs::create_dir(&dir).unwrap();
715
716 let config = TreeConfig::default();
717 let tree = build_tree(temp.path(), &config).unwrap();
718
719 assert_eq!(tree.children.len(), 2);
721 assert!(tree.children[0].is_dir);
722 assert!(!tree.children[1].is_dir);
723 }
724
725 #[test]
726 fn test_render_tree_multiple_files_last_item() {
727 let mut root = FileNode::new_dir(PathBuf::from("project"));
728 root.children
729 .push(FileNode::new_file(PathBuf::from("a.txt"), 100));
730 root.children
731 .push(FileNode::new_file(PathBuf::from("b.txt"), 100));
732 root.children
733 .push(FileNode::new_file(PathBuf::from("c.txt"), 100));
734
735 let config = TreeConfig::default();
736 let output = render_tree(&root, &config);
737
738 assert!(output.contains("└── c.txt"));
740 assert!(output.contains("├── a.txt"));
742 assert!(output.contains("├── b.txt"));
743 }
744}