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        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
207/// Build a single file node, returning an empty sentinel when filtered out.
208fn 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
232/// Read a directory and sort entries: directories first, then alphabetically.
233fn 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/// Format a file size for display
256#[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/// Render the tree to a string
274#[must_use]
275pub fn render_tree(root: &FileNode, config: &TreeConfig) -> String {
276    let mut output = String::new();
277
278    // Root directory name
279    output.push_str(&root.name);
280    output.push_str("/\n");
281
282    // Render children
283    render_node_children(&root.children, config, "", &mut output);
284
285    // Summary line
286    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        // Build line
312        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            // File info
320            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        // Recurse for directories
331        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
338/// Display the file tree to stdout
339///
340/// # Errors
341///
342/// Returns an error if the path cannot be read.
343pub 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        // Should not recurse into directories
445        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        // Should only include .js files
459        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    // Additional coverage tests
507
508    #[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        // Path like "/" has no file_name component
517        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        // Size should not be in parentheses next to filename
583        // (summary line still includes total size)
584        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        // Hidden files should be excluded
610        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        // node_modules should be excluded
626        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        // target should be excluded
642        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(); // Empty mime type
671        root.children.push(file);
672
673        let config = TreeConfig::default().with_mime_types(true);
674        let output = render_tree(&root, &config);
675
676        // Should not have brackets for empty mime type
677        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        // Directory should come before file despite alphabetical order
719        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        // Last item should use └── connector
738        assert!(output.contains("└── c.txt"));
739        // Middle items should use ├── connector
740        assert!(output.contains("├── a.txt"));
741        assert!(output.contains("├── b.txt"));
742    }
743}