Skip to main content

probador/
tree.rs

1//! File Tree Visualization
2//!
3//! Displays the directory structure of served files with MIME types and sizes.
4//!
5//! ## Example Output
6//!
7//! ```text
8//! demos/realtime-transcription/
9//! ├── index.html (2.3 KB) [text/html]
10//! ├── styles.css (1.1 KB) [text/css]
11//! ├── pkg/
12//! │   ├── realtime_wasm.js (45 KB) [text/javascript]
13//! │   └── realtime_wasm_bg.wasm (1.2 MB) [application/wasm]
14//! └── worker.js (5.6 KB) [text/javascript]
15//!
16//! Total: 5 files, 1.3 MB
17//! ```
18
19#![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/// File node in the tree
34#[derive(Debug, Clone)]
35pub struct FileNode {
36    /// File or directory name
37    pub name: String,
38    /// Full path
39    pub path: PathBuf,
40    /// File size in bytes (0 for directories)
41    pub size: u64,
42    /// MIME type (empty for directories)
43    pub mime_type: String,
44    /// Whether this is a directory
45    pub is_dir: bool,
46    /// Child nodes
47    pub children: Vec<FileNode>,
48}
49
50impl FileNode {
51    /// Create a new file node
52    #[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    /// Create a new directory node
71    #[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    /// Get total size including children
89    #[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    /// Count total files (excluding directories)
99    #[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/// Configuration for tree display
110#[derive(Debug, Clone)]
111pub struct TreeConfig {
112    /// Maximum depth to display (None = unlimited)
113    pub max_depth: Option<usize>,
114    /// Filter pattern (glob)
115    pub filter: Option<Pattern>,
116    /// Show file sizes
117    pub show_sizes: bool,
118    /// Show MIME types
119    pub show_mime_types: bool,
120    /// Use colors
121    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    /// Set maximum depth
138    #[must_use]
139    pub fn with_depth(mut self, depth: Option<usize>) -> Self {
140        self.max_depth = depth;
141        self
142    }
143
144    /// Set filter pattern
145    #[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    /// Set whether to show sizes
152    #[must_use]
153    pub const fn with_sizes(mut self, show: bool) -> Self {
154        self.show_sizes = show;
155        self
156    }
157
158    /// Set whether to show MIME types
159    #[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
166/// Build a file tree from a directory
167///
168/// # Errors
169///
170/// Returns an error if the path cannot be read or doesn't exist.
171pub 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        // Check filter
184        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 empty node that will be filtered
191                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    // Directory
206    let mut node = FileNode::new_dir(path.to_path_buf());
207
208    // Check depth limit
209    if let Some(max_depth) = config.max_depth {
210        if current_depth >= max_depth {
211            return Ok(node);
212        }
213    }
214
215    // Read directory contents
216    let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(Result::ok).collect();
217
218    // Sort: directories first, then alphabetically
219    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        // Skip hidden files and common ignore patterns
233        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                // Filter out empty nodes (filtered files)
245                if !child.name.is_empty() {
246                    node.children.push(child);
247                }
248            }
249            Err(_) => continue, // Skip unreadable entries
250        }
251    }
252
253    Ok(node)
254}
255
256/// Format a file size for display
257#[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/// Render the tree to a string
275#[must_use]
276pub fn render_tree(root: &FileNode, config: &TreeConfig) -> String {
277    let mut output = String::new();
278
279    // Root directory name
280    output.push_str(&root.name);
281    output.push_str("/\n");
282
283    // Render children
284    render_node_children(&root.children, config, "", &mut output);
285
286    // Summary line
287    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        // Build line
313        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            // File info
321            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        // Recurse for directories
332        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
339/// Display the file tree to stdout
340///
341/// # Errors
342///
343/// Returns an error if the path cannot be read.
344pub 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        // Should not recurse into directories
446        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        // Should only include .js files
460        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    // Additional coverage tests
508
509    #[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        // Path like "/" has no file_name component
518        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        // Size should not be in parentheses next to filename
584        // (summary line still includes total size)
585        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        // Hidden files should be excluded
611        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        // node_modules should be excluded
627        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        // target should be excluded
643        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(); // Empty mime type
672        root.children.push(file);
673
674        let config = TreeConfig::default().with_mime_types(true);
675        let output = render_tree(&root, &config);
676
677        // Should not have brackets for empty mime type
678        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        // Directory should come before file despite alphabetical order
720        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        // Last item should use └── connector
739        assert!(output.contains("└── c.txt"));
740        // Middle items should use ├── connector
741        assert!(output.contains("├── a.txt"));
742        assert!(output.contains("├── b.txt"));
743    }
744}